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
This commit is contained in:
@@ -13,12 +13,56 @@ from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry
|
||||
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry
|
||||
from services.audit import log_action
|
||||
|
||||
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
|
||||
|
||||
|
||||
def _csv_response(rows, header, filename):
|
||||
"""Build a UTF-8 CSV Response with BOM (Excel-friendly)."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
|
||||
|
||||
def _period_bounds(year, month):
|
||||
"""Return (period_start, period_end) ISO dates for a fiscal month."""
|
||||
period_start = f'{year}-{month:02d}-01'
|
||||
if month == 12:
|
||||
period_end = f'{year + 1}-01-01'
|
||||
else:
|
||||
period_end = f'{year}-{month + 1:02d}-01'
|
||||
return period_start, period_end
|
||||
|
||||
|
||||
def _supplier_paid_totals(cur):
|
||||
"""Return {po_id: paid_amount} from supplier_payments.
|
||||
|
||||
The table is created on first supplier payment, so tenants that never paid
|
||||
a supplier simply have no data (and no table) yet.
|
||||
"""
|
||||
cur.execute("SELECT to_regclass('public.supplier_payments')")
|
||||
if not cur.fetchone()[0]:
|
||||
return {}
|
||||
cur.execute("""
|
||||
SELECT po_id, COALESCE(SUM(amount), 0)
|
||||
FROM supplier_payments
|
||||
WHERE po_id IS NOT NULL
|
||||
GROUP BY po_id
|
||||
""")
|
||||
return {r[0]: float(r[1]) for r in cur.fetchall()}
|
||||
|
||||
|
||||
|
||||
# ─── Chart of Accounts ─────────────────────────────
|
||||
|
||||
@accounting_bp.route('/accounts', methods=['GET'])
|
||||
@@ -309,29 +353,11 @@ def create_entry():
|
||||
|
||||
# ─── Financial Reports ──────────────────────────────
|
||||
|
||||
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def trial_balance():
|
||||
"""Balanza de comprobacion for a period.
|
||||
|
||||
Query params:
|
||||
year: int (default current year)
|
||||
month: int (default current month)
|
||||
|
||||
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
def _trial_balance_rows(conn, year, month):
|
||||
"""Balanza de comprobacion rows for a fiscal month."""
|
||||
cur = conn.cursor()
|
||||
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
# Period start/end dates
|
||||
period_start = f'{year}-{month:02d}-01'
|
||||
if month == 12:
|
||||
period_end = f'{year + 1}-01-01'
|
||||
else:
|
||||
period_end = f'{year}-{month + 1:02d}-01'
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
|
||||
cur.execute("""
|
||||
WITH initial_balances AS (
|
||||
@@ -384,31 +410,62 @@ def trial_balance():
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
return rows
|
||||
|
||||
|
||||
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def income_statement():
|
||||
"""Estado de resultados for a period.
|
||||
def trial_balance():
|
||||
"""Balanza de comprobacion for a period.
|
||||
|
||||
Query params: year, month
|
||||
Query params:
|
||||
year: int (default current year)
|
||||
month: int (default current month)
|
||||
|
||||
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
period_start = f'{year}-{month:02d}-01'
|
||||
if month == 12:
|
||||
period_end = f'{year + 1}-01-01'
|
||||
else:
|
||||
period_end = f'{year}-{month + 1:02d}-01'
|
||||
rows = _trial_balance_rows(conn, year, month)
|
||||
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
|
||||
|
||||
@accounting_bp.route('/trial-balance/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_trial_balance():
|
||||
"""Export the balanza de comprobacion as CSV (default) or JSON.
|
||||
|
||||
Query params: year, month, format=csv|json
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
|
||||
rows = _trial_balance_rows(conn, year, month)
|
||||
conn.close()
|
||||
|
||||
if fmt == 'json':
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
|
||||
return _csv_response(
|
||||
[[r['code'], r['name'], r['type'], r['saldo_inicial'], r['cargos'],
|
||||
r['abonos'], r['saldo_final']] for r in rows],
|
||||
['Cuenta', 'Nombre', 'Tipo', 'Saldo inicial', 'Cargos', 'Abonos', 'Saldo final'],
|
||||
f'balanza_{year}-{month:02d}.csv'
|
||||
)
|
||||
|
||||
|
||||
def _income_statement_data(conn, year, month):
|
||||
"""Estado de resultados grouped by section for a fiscal month."""
|
||||
cur = conn.cursor()
|
||||
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
|
||||
cur.execute("""
|
||||
SELECT a.id, a.code, a.name, a.type,
|
||||
@@ -456,16 +513,215 @@ def income_statement():
|
||||
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
return {
|
||||
'period': {'year': year, 'month': month},
|
||||
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
|
||||
'costos': {'items': costos, 'total': round(total_costos, 2)},
|
||||
'utilidad_bruta': utilidad_bruta,
|
||||
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
|
||||
'utilidad_neta': utilidad_neta,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def income_statement():
|
||||
"""Estado de resultados for a period.
|
||||
|
||||
Query params: year, month
|
||||
|
||||
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
data = _income_statement_data(conn, year, month)
|
||||
|
||||
conn.close()
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@accounting_bp.route('/income-statement/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_income_statement():
|
||||
"""Export the estado de resultados as CSV (default) or JSON.
|
||||
|
||||
Query params: year, month, format=csv|json
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
|
||||
data = _income_statement_data(conn, year, month)
|
||||
conn.close()
|
||||
|
||||
if fmt == 'json':
|
||||
return jsonify(data)
|
||||
|
||||
rows = []
|
||||
for item in data['ingresos']['items']:
|
||||
rows.append(['Ingresos', item['code'], item['name'], item['amount']])
|
||||
rows.append(['Ingresos', '', 'Total ingresos', data['ingresos']['total']])
|
||||
for item in data['costos']['items']:
|
||||
rows.append(['Costos', item['code'], item['name'], item['amount']])
|
||||
rows.append(['Costos', '', 'Total costos', data['costos']['total']])
|
||||
rows.append(['', '', 'Utilidad bruta', data['utilidad_bruta']])
|
||||
for item in data['gastos']['items']:
|
||||
rows.append(['Gastos', item['code'], item['name'], item['amount']])
|
||||
rows.append(['Gastos', '', 'Total gastos', data['gastos']['total']])
|
||||
rows.append(['', '', 'Utilidad neta', data['utilidad_neta']])
|
||||
|
||||
return _csv_response(
|
||||
rows,
|
||||
['Seccion', 'Cuenta', 'Nombre', 'Importe'],
|
||||
f'estado_resultados_{year}-{month:02d}.csv'
|
||||
)
|
||||
|
||||
|
||||
@accounting_bp.route('/entries/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_entries():
|
||||
"""Export the polizas (journal entries) of a period with their lines.
|
||||
|
||||
Query params: date_from, date_to, type, reference_type, format=csv|json
|
||||
|
||||
CSV output has one row per journal entry line (accountant-friendly).
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
entry_type = request.args.get('type')
|
||||
ref_type = request.args.get('reference_type')
|
||||
|
||||
where_clauses = ["e.status = 'posted'"]
|
||||
params = []
|
||||
if date_from:
|
||||
where_clauses.append("e.date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where_clauses.append("e.date <= %s")
|
||||
params.append(date_to)
|
||||
if entry_type:
|
||||
where_clauses.append("e.type = %s")
|
||||
params.append(entry_type)
|
||||
if ref_type:
|
||||
where_clauses.append("e.reference_type = %s")
|
||||
params.append(ref_type)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT e.entry_number, e.date, e.type, e.description,
|
||||
e.reference_type, e.reference_id,
|
||||
a.code, a.name, l.description, l.debit, l.credit
|
||||
FROM journal_entry_lines l
|
||||
JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||
JOIN accounts a ON l.account_id = a.id
|
||||
WHERE {where}
|
||||
ORDER BY e.date, e.entry_number, l.id
|
||||
""", params)
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
rows.append({
|
||||
'entry_number': r[0],
|
||||
'date': r[1].isoformat() if r[1] else None,
|
||||
'type': r[2],
|
||||
'entry_description': r[3],
|
||||
'reference_type': r[4],
|
||||
'reference_id': r[5],
|
||||
'account_code': r[6],
|
||||
'account_name': r[7],
|
||||
'line_description': r[8],
|
||||
'debit': float(r[9] or 0),
|
||||
'credit': float(r[10] or 0),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if fmt == 'json':
|
||||
return jsonify({'data': rows, 'count': len(rows)})
|
||||
|
||||
return _csv_response(
|
||||
[[r['entry_number'], r['date'], r['type'], r['entry_description'],
|
||||
r['reference_type'], r['reference_id'], r['account_code'],
|
||||
r['account_name'], r['line_description'], r['debit'], r['credit']]
|
||||
for r in rows],
|
||||
['Poliza', 'Fecha', 'Tipo', 'Descripcion poliza', 'Referencia', 'Ref ID',
|
||||
'Cuenta', 'Nombre cuenta', 'Descripcion', 'Debe', 'Haber'],
|
||||
f'polizas_{date.today().isoformat()}.csv'
|
||||
)
|
||||
|
||||
|
||||
@accounting_bp.route('/diot/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_diot():
|
||||
"""Export the DIOT (operations with third parties) for a fiscal month.
|
||||
|
||||
Data source: purchase orders received during the month, grouped by
|
||||
supplier. All amounts are expressed in the purchase-order currency (MXN
|
||||
for locally created orders).
|
||||
|
||||
Query params: year, month, format=csv|json
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.rfc, s.name,
|
||||
COALESCE(SUM(po.subtotal), 0) as base,
|
||||
COALESCE(SUM(po.tax_total), 0) as iva,
|
||||
COALESCE(SUM(po.total), 0) as total
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
WHERE po.status NOT IN ('draft', 'cancelled')
|
||||
AND po.received_at >= %s AND po.received_at < %s
|
||||
GROUP BY s.rfc, s.name
|
||||
ORDER BY s.name
|
||||
""", (period_start, period_end))
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
rows.append({
|
||||
'rfc': r[0] or '',
|
||||
'supplier_name': r[1],
|
||||
'tipo_tercero': '04', # Proveedor
|
||||
'tipo_operacion': '02', # Compra de bienes
|
||||
'base': round(float(r[2]), 2),
|
||||
'iva_16': round(float(r[3]), 2),
|
||||
'iva_retenido': 0.0,
|
||||
'total': round(float(r[4]), 2),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if fmt == 'json':
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month},
|
||||
'count': len(rows)})
|
||||
|
||||
return _csv_response(
|
||||
[[r['tipo_tercero'], r['tipo_operacion'], r['rfc'], r['supplier_name'],
|
||||
r['base'], r['iva_16'], r['iva_retenido'], r['total']] for r in rows],
|
||||
['Tipo de tercero', 'Tipo de operacion', 'RFC', 'Nombre', 'Base',
|
||||
'IVA 16%', 'IVA retenido', 'Monto total'],
|
||||
f'diot_{year}-{month:02d}.csv'
|
||||
)
|
||||
|
||||
|
||||
@accounting_bp.route('/balance-sheet', methods=['GET'])
|
||||
@@ -578,6 +834,7 @@ def aging_report():
|
||||
|
||||
rows = []
|
||||
if report_type == 'payable':
|
||||
paid_by_po = _supplier_paid_totals(cur)
|
||||
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
|
||||
cur.execute("""
|
||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
||||
@@ -596,7 +853,7 @@ def aging_report():
|
||||
status = r[5]
|
||||
vendor_name = r[7]
|
||||
|
||||
paid = 0 # TODO: sum supplier payments when that table is added
|
||||
paid = paid_by_po.get(po_id, 0.0)
|
||||
balance = round(total - paid, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
@@ -772,6 +1029,7 @@ def export_aging():
|
||||
|
||||
if report_type == 'payable':
|
||||
# Payables always CSV for now
|
||||
paid_by_po = _supplier_paid_totals(cur)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
|
||||
@@ -792,10 +1050,10 @@ def export_aging():
|
||||
expected = r[4]
|
||||
status = r[5]
|
||||
vendor = r[6]
|
||||
paid = 0
|
||||
paid = paid_by_po.get(po_id, 0.0)
|
||||
balance = round(total - paid, 2)
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
|
||||
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
|
||||
status_label = 'Vencida' if days_overdue > 0 else ('Parcial' if paid > 0 else 'Pendiente')
|
||||
writer.writerow([
|
||||
invoice, vendor,
|
||||
created_at.strftime('%Y-%m-%d') if created_at else '',
|
||||
@@ -916,6 +1174,100 @@ def export_aging():
|
||||
)
|
||||
|
||||
|
||||
# ─── Accounts Payable payments ─────────────────────
|
||||
|
||||
@accounting_bp.route('/supplier-payments', methods=['POST'])
|
||||
@require_auth('accounting.create')
|
||||
def create_supplier_payment():
|
||||
"""Register a payment to a supplier against a purchase order (CxP).
|
||||
|
||||
Body: {po_id: int, amount: float, payment_method: str, reference: str}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('po_id'):
|
||||
return jsonify({'error': 'po_id is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = record_supplier_payment_entry(conn, data)
|
||||
|
||||
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
|
||||
new_value={'amount': float(data.get('amount', 0)),
|
||||
'payment_id': result['payment_id'],
|
||||
'status': result['status']})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result), 201
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@accounting_bp.route('/supplier-payments', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def list_supplier_payments():
|
||||
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT to_regclass('public.supplier_payments')")
|
||||
if not cur.fetchone()[0]:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
if request.args.get('po_id'):
|
||||
where_clauses.append("sp.po_id = %s")
|
||||
params.append(int(request.args.get('po_id')))
|
||||
if request.args.get('supplier_id'):
|
||||
where_clauses.append("sp.supplier_id = %s")
|
||||
params.append(int(request.args.get('supplier_id')))
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
|
||||
sp.reference, sp.created_at, s.name as supplier_name
|
||||
FROM supplier_payments sp
|
||||
LEFT JOIN suppliers s ON sp.supplier_id = s.id
|
||||
WHERE {where}
|
||||
ORDER BY sp.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
rows.append({
|
||||
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
|
||||
'amount': float(r[3]) if r[3] else 0,
|
||||
'payment_method': r[4], 'reference': r[5],
|
||||
'created_at': str(r[6]) if r[6] else None,
|
||||
'supplier_name': r[7],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return jsonify({
|
||||
'data': rows,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||
})
|
||||
|
||||
|
||||
# ─── Fiscal Periods ────────────────────────────────
|
||||
|
||||
@accounting_bp.route('/periods', methods=['GET'])
|
||||
|
||||
@@ -254,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
||||
name = row[1]
|
||||
parts = name.split()
|
||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico', 'driver': 'Repartidor'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
258
pos/blueprints/b2b_bp.py
Normal file
258
pos/blueprints/b2b_bp.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# /home/Autopartes/pos/blueprints/b2b_bp.py
|
||||
"""Nexus B2B yonke marketplace blueprint.
|
||||
|
||||
Wholesale trading of used parts between yonkes (deshuesaderos): one tenant
|
||||
publishes its used pieces, another buys them. Listings and orders live in the
|
||||
MASTER DB; the pieces stay in each tenant DB.
|
||||
|
||||
All endpoints live under /pos/api/b2b.
|
||||
Permissions: yonke.view for reads, yonke.manage for writes.
|
||||
|
||||
Seller:
|
||||
GET /pos/api/b2b/my-listings my published listings
|
||||
POST /pos/api/b2b/listings publish/refresh a listing
|
||||
PATCH /pos/api/b2b/listings/<id> edit price/status/notes
|
||||
DELETE /pos/api/b2b/listings/<id> soft-delete (status='removed')
|
||||
GET /pos/api/b2b/seller/status {is_seller, listings_count, ...}
|
||||
Buyer:
|
||||
GET /pos/api/b2b/catalog available listings from other tenants
|
||||
GET /pos/api/b2b/listings/<id> listing detail (+ seller_name)
|
||||
Orders (both):
|
||||
POST /pos/api/b2b/orders create an order from listings
|
||||
GET /pos/api/b2b/orders my orders as buyer
|
||||
GET /pos/api/b2b/orders/incoming my orders as seller
|
||||
GET /pos/api/b2b/orders/<id> order detail (participants only)
|
||||
PUT /pos/api/b2b/orders/<id>/status transition the order status
|
||||
"""
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from services import b2b_service
|
||||
from tenant_db import get_master_conn
|
||||
|
||||
b2b_bp = Blueprint('b2b', __name__, url_prefix='/pos/api/b2b')
|
||||
|
||||
|
||||
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 _error_response(e):
|
||||
"""Map a service exception to (json, status); never leaks a 500."""
|
||||
if isinstance(e, b2b_service.B2BForbidden):
|
||||
return jsonify({'error': str(e)}), 403
|
||||
if isinstance(e, b2b_service.B2BNotFound):
|
||||
return jsonify({'error': str(e)}), 404
|
||||
if isinstance(e, b2b_service.B2BValidationError):
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# SELLER
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/my-listings', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def my_listings():
|
||||
"""Listings published by the current tenant."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
data = b2b_service.list_my_listings(
|
||||
master, g.tenant_id, status=request.args.get('status'))
|
||||
return jsonify({'data': data})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def publish_listing():
|
||||
"""Publish (or refresh) one of my used/rebuild pieces to the B2B catalog."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.publish_listing(master, g.tenant_id, data)
|
||||
master.commit()
|
||||
return jsonify(listing), 201
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['PATCH'])
|
||||
@require_auth('yonke.manage')
|
||||
def update_listing(listing_id):
|
||||
"""Edit price_wholesale / status / notes of one of my listings."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.update_listing(master, g.tenant_id, listing_id, data)
|
||||
master.commit()
|
||||
return jsonify(listing)
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['DELETE'])
|
||||
@require_auth('yonke.manage')
|
||||
def remove_listing(listing_id):
|
||||
"""Soft-delete a listing (status='removed')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
b2b_service.remove_listing(master, g.tenant_id, listing_id)
|
||||
master.commit()
|
||||
return jsonify({'ok': True, 'id': listing_id, 'status': 'removed'})
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/seller/status', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def seller_status():
|
||||
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify(b2b_service.get_seller_status(master, g.tenant_id))
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# BUYER — catalog
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/catalog', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def catalog():
|
||||
"""Available listings from OTHER tenants, with seller_name and pagination."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
items, pagination = b2b_service.list_catalog(
|
||||
master, g.tenant_id,
|
||||
filters={
|
||||
'q': request.args.get('q'),
|
||||
'make': request.args.get('make'),
|
||||
'model': request.args.get('model'),
|
||||
'year': request.args.get('year'),
|
||||
'grade': request.args.get('grade'),
|
||||
},
|
||||
page=_int_arg('page', 1, 1, 10**9),
|
||||
per_page=_int_arg('per_page', 12, 1, 100),
|
||||
)
|
||||
return jsonify({'data': items, 'pagination': pagination})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_listing(listing_id):
|
||||
"""Listing detail with seller_name."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.get_listing(master, listing_id)
|
||||
if not listing:
|
||||
return jsonify({'error': 'Listing no encontrado'}), 404
|
||||
return jsonify(listing)
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/orders', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def create_order():
|
||||
"""Create a pending order from one or more listings (single seller)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
order = b2b_service.create_order(
|
||||
master, g.tenant_id, data.get('items'), notes=data.get('notes'))
|
||||
master.commit()
|
||||
return jsonify(order), 201
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_orders():
|
||||
"""My orders as buyer (role='buyer')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'buyer')})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/incoming', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_incoming_orders():
|
||||
"""Orders I must fulfil as seller (role='seller')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'seller')})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/<int:order_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_order(order_id):
|
||||
"""Order detail (buyer or seller of that order only)."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify(b2b_service.get_order(master, g.tenant_id, order_id))
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
||||
@require_auth('yonke.manage')
|
||||
def update_order_status(order_id):
|
||||
"""Transition an order status (role-checked). 'received' also stocks the buyer."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
result = b2b_service.update_order_status(
|
||||
master, g.tenant_id, order_id, data.get('status'),
|
||||
branch_id=g.branch_id)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
@@ -1,12 +1,15 @@
|
||||
# /home/Autopartes/pos/blueprints/cashregister_bp.py
|
||||
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||
|
||||
|
||||
@@ -18,6 +21,29 @@ def _can_operate_register():
|
||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||
|
||||
|
||||
def _int_arg(name, default=None, min_value=None, max_value=None):
|
||||
"""Parse an integer query-string argument safely.
|
||||
|
||||
Bad input (e.g. ?per_page=abc) falls back to the default instead of
|
||||
raising ValueError and returning a 500.
|
||||
"""
|
||||
raw = request.args.get(name)
|
||||
if raw is None or raw == '':
|
||||
value = default
|
||||
else:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if value is None:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
value = min_value
|
||||
if max_value is not None and value > max_value:
|
||||
value = max_value
|
||||
return value
|
||||
|
||||
|
||||
@cashregister_bp.route('/open', methods=['POST'])
|
||||
@require_auth()
|
||||
def open_register():
|
||||
@@ -220,8 +246,35 @@ def _compute_register_summary(conn, register_id):
|
||||
total_sales += amount
|
||||
total_sales_count += count
|
||||
|
||||
# Cash sales specifically (for expected cash calculation)
|
||||
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
|
||||
# Cash received for the expected-cash calculation:
|
||||
# - credit sales collect nothing at sale time (their abonos arrive as
|
||||
# cash movements), so they contribute 0
|
||||
# - cash sales contribute their total (change is discounted below)
|
||||
# - mixed/other sales contribute only their cash portion from sale_payments
|
||||
# Layaways already log every deposit as a cash movement, so their
|
||||
# conversion sale is excluded here to avoid counting that cash twice.
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(
|
||||
CASE
|
||||
WHEN s.sale_type = 'credit' THEN 0
|
||||
WHEN s.payment_method = 'efectivo' THEN s.total
|
||||
ELSE COALESCE(cash.cash_amount, 0)
|
||||
END
|
||||
), 0)
|
||||
FROM sales s
|
||||
LEFT JOIN (
|
||||
SELECT sale_id, SUM(amount) AS cash_amount
|
||||
FROM sale_payments
|
||||
WHERE method = 'efectivo'
|
||||
GROUP BY sale_id
|
||||
) cash ON cash.sale_id = s.id
|
||||
WHERE s.register_id = %s AND s.status = 'completed'
|
||||
AND s.id NOT IN (
|
||||
SELECT converted_sale_id FROM layaways
|
||||
WHERE converted_sale_id IS NOT NULL
|
||||
)
|
||||
""", (register_id,))
|
||||
cash_from_sales = float(cur.fetchone()[0])
|
||||
|
||||
# Change given (cash out)
|
||||
cur.execute("""
|
||||
@@ -230,15 +283,26 @@ def _compute_register_summary(conn, register_id):
|
||||
""", (register_id,))
|
||||
change_given = float(cur.fetchone()[0])
|
||||
|
||||
# Cash movements
|
||||
# Cash movements. Refunds from devoluciones (reason 'Devolucion #...') are
|
||||
# kept out of the generic 'out' bucket and subtracted separately below, so
|
||||
# a refund lowers the expected cash exactly once.
|
||||
cur.execute("""
|
||||
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
||||
WHERE register_id = %s GROUP BY type
|
||||
WHERE register_id = %s
|
||||
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
|
||||
GROUP BY type
|
||||
""", (register_id,))
|
||||
movements = {'in': 0.0, 'out': 0.0}
|
||||
for r in cur.fetchall():
|
||||
movements[r[0]] = float(r[1])
|
||||
|
||||
# Cash refunds handed back at this register (devoluciones in cash)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM cash_movements
|
||||
WHERE register_id = %s AND type = 'out' AND reason LIKE 'Devolucion #%%'
|
||||
""", (register_id,))
|
||||
refunds_out = float(cur.fetchone()[0])
|
||||
|
||||
# Cancelled sales
|
||||
cur.execute("""
|
||||
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
|
||||
@@ -248,9 +312,10 @@ def _compute_register_summary(conn, register_id):
|
||||
cancelled_count = cancelled[0]
|
||||
cancelled_amount = float(cancelled[1])
|
||||
|
||||
# Expected cash = opening + cash sales - change + cash_in - cash_out
|
||||
# Expected cash = opening + cash sales - change + cash_in - cash_out - refunds
|
||||
expected_cash = round(
|
||||
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
|
||||
opening_amount + cash_from_sales - change_given
|
||||
+ movements['in'] - movements['out'] - refunds_out,
|
||||
2
|
||||
)
|
||||
|
||||
@@ -277,6 +342,7 @@ def _compute_register_summary(conn, register_id):
|
||||
'change_given': round(change_given, 2),
|
||||
'cash_movements_in': round(movements['in'], 2),
|
||||
'cash_movements_out': round(movements['out'], 2),
|
||||
'refunds_out': round(refunds_out, 2),
|
||||
'movement_detail': movement_detail,
|
||||
'cancelled_count': cancelled_count,
|
||||
'cancelled_amount': round(cancelled_amount, 2),
|
||||
@@ -330,7 +396,7 @@ def cut_z():
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id FROM cash_registers
|
||||
SELECT id, register_number FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
@@ -338,7 +404,7 @@ def cut_z():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No open register'}), 400
|
||||
|
||||
register_id = reg[0]
|
||||
register_id, register_number = reg
|
||||
summary = _compute_register_summary(conn, register_id)
|
||||
|
||||
expected = summary['expected_cash']
|
||||
@@ -362,6 +428,22 @@ def cut_z():
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): move the counted cash to Bancos.
|
||||
# The cut is already committed, so a failure here never loses the corte.
|
||||
try:
|
||||
from services.accounting_engine import record_cash_cut_entry
|
||||
record_cash_cut_entry(conn, {
|
||||
'id': register_id,
|
||||
'register_number': register_number,
|
||||
'closing_amount': closing_amount,
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logger.error(
|
||||
f'Accounting entry for cash cut #{register_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
# Push notification to owner if cash difference > $500
|
||||
if abs(difference) > 500:
|
||||
try:
|
||||
@@ -410,8 +492,8 @@ def register_history():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["cr.status = 'closed'"]
|
||||
params = []
|
||||
@@ -422,7 +504,7 @@ def register_history():
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
employee_id = _int_arg('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
@@ -435,7 +517,7 @@ def register_history():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("cr.employee_id = %s")
|
||||
params.append(int(employee_id))
|
||||
params.append(employee_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
|
||||
@@ -19,11 +19,32 @@ from tenant_db import get_master_conn, get_tenant_conn
|
||||
from services import catalog_service
|
||||
from services.vin_decoder import decode_vin
|
||||
from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate
|
||||
from services.inventory_vehicle_compat import get_inventory_by_vehicle
|
||||
from config import CATALOG_OEM_ENABLED
|
||||
|
||||
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
|
||||
|
||||
|
||||
# ─── Guard: tablas TecDoc retiradas (junio 2026) ──────────────────────────
|
||||
_TECDOC_TABLES_CACHE = {'checked': False, 'exists': False}
|
||||
|
||||
|
||||
def _tecdoc_available(conn):
|
||||
"""True si las tablas TecDoc del catálogo OEM existen en la maestra."""
|
||||
if _TECDOC_TABLES_CACHE['checked']:
|
||||
return _TECDOC_TABLES_CACHE['exists']
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='parts' LIMIT 1")
|
||||
exists = cur.fetchone() is not None
|
||||
cur.close()
|
||||
except Exception:
|
||||
exists = False
|
||||
_TECDOC_TABLES_CACHE['checked'] = True
|
||||
_TECDOC_TABLES_CACHE['exists'] = exists
|
||||
return exists
|
||||
|
||||
|
||||
def _oem_blocked():
|
||||
"""Return a 403 response if OEM catalog is disabled."""
|
||||
if not CATALOG_OEM_ENABLED:
|
||||
@@ -66,6 +87,9 @@ def _with_conns(fn):
|
||||
branch_id = request.args.get('branch_id', g.branch_id)
|
||||
return fn(master, tenant, branch_id)
|
||||
except Exception as e:
|
||||
if _is_retired_schema_error(e):
|
||||
return jsonify({'data': [], 'oem_disabled': True,
|
||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
if master:
|
||||
@@ -76,6 +100,15 @@ def _with_conns(fn):
|
||||
except: pass
|
||||
|
||||
|
||||
def _is_retired_schema_error(e):
|
||||
"""True si el error es por tablas TecDoc retiradas del esquema."""
|
||||
try:
|
||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
||||
return isinstance(e, (UndefinedTable, UndefinedColumn))
|
||||
except Exception:
|
||||
return 'does not exist' in str(e)
|
||||
|
||||
|
||||
def _master_only(fn):
|
||||
"""Helper: open only master connection for hierarchy endpoints."""
|
||||
master = None
|
||||
@@ -83,6 +116,9 @@ def _master_only(fn):
|
||||
master = get_master_conn()
|
||||
return fn(master)
|
||||
except Exception as e:
|
||||
if _is_retired_schema_error(e):
|
||||
return jsonify({'data': [], 'oem_disabled': True,
|
||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
if master:
|
||||
@@ -487,6 +523,33 @@ def decode_vin_route(vin):
|
||||
|
||||
# ─── Plate Lookup ───
|
||||
|
||||
|
||||
|
||||
@catalog_bp.route('/vehicle-inventory', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def vehicle_inventory():
|
||||
"""Local inventory items compatible with a vehicle (model_year_engine_id)."""
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id requerido'}), 400
|
||||
tenant = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
rows = get_inventory_by_vehicle(tenant, None, mye_id,
|
||||
branch_id=getattr(g, 'branch_id', None))
|
||||
items = [{
|
||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
|
||||
'price_1': float(r[4] or 0), 'price_2': float(r[5] or 0),
|
||||
'price_3': float(r[6] or 0), 'image_url': r[7] or '',
|
||||
'stock': int(r[9] or 0),
|
||||
} for r in rows]
|
||||
return jsonify({'data': items})
|
||||
finally:
|
||||
try:
|
||||
tenant.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@catalog_bp.route('/plate/<plate>', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def plate_lookup(plate):
|
||||
@@ -541,6 +604,9 @@ def plate_lookup(plate):
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
if _is_retired_schema_error(e):
|
||||
return jsonify({'data': [], 'oem_disabled': True,
|
||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
if tenant:
|
||||
@@ -576,6 +642,9 @@ def plate_register():
|
||||
)
|
||||
return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
|
||||
except Exception as e:
|
||||
if _is_retired_schema_error(e):
|
||||
return jsonify({'data': [], 'oem_disabled': True,
|
||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
if tenant:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Config blueprint: tenant configuration, branches, theming."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
@@ -14,6 +15,7 @@ _DEFAULT_ROLE_PERMISSIONS = {
|
||||
'owner': [], # owner bypasses permission checks
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'yonke.view', 'yonke.manage',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
@@ -40,6 +42,7 @@ _DEFAULT_ROLE_PERMISSIONS = {
|
||||
'fleet.view'],
|
||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||
'mechanic': ['workshop.view'],
|
||||
'driver': ['pos.remission'],
|
||||
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
|
||||
'customers.view', 'customers.create'],
|
||||
}
|
||||
@@ -108,6 +111,10 @@ _AVAILABLE_PERMISSIONS = [
|
||||
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
|
||||
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
|
||||
]},
|
||||
{'module': 'Yonke', 'permissions': [
|
||||
{'key': 'yonke.view', 'label': 'Ver Yonke (partes usadas)'},
|
||||
{'key': 'yonke.manage', 'label': 'Gestionar Yonke (piezas y consignaciones)'},
|
||||
]},
|
||||
{'module': 'Catálogo', 'permissions': [
|
||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
|
||||
@@ -433,7 +440,7 @@ def create_employee():
|
||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic', 'driver']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -455,6 +462,14 @@ def create_employee():
|
||||
(emp_id, perm)
|
||||
)
|
||||
|
||||
# Drivers also get a courier record so they can be assigned to deliveries
|
||||
if data['role'] == 'driver':
|
||||
cur.execute("""
|
||||
INSERT INTO couriers (tenant_id, name, code, is_active)
|
||||
VALUES (%s, %s, %s, true)
|
||||
ON CONFLICT (tenant_id, code) DO NOTHING
|
||||
""", (g.tenant_id, data['name'], f'emp_{emp_id}'))
|
||||
|
||||
from services.audit import log_action
|
||||
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
|
||||
new_value={'name': data['name'], 'role': data['role']})
|
||||
@@ -617,6 +632,15 @@ def update_employee(emp_id):
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
)
|
||||
# Drivers also get a courier record so they can be assigned to deliveries
|
||||
if data['role'] == 'driver':
|
||||
cur.execute("SELECT name FROM employees WHERE id = %s", (emp_id,))
|
||||
emp_name = cur.fetchone()[0]
|
||||
cur.execute("""
|
||||
INSERT INTO couriers (tenant_id, name, code, is_active)
|
||||
VALUES (%s, %s, %s, true)
|
||||
ON CONFLICT (tenant_id, code) DO NOTHING
|
||||
""", (g.tenant_id, emp_name, f'emp_{emp_id}'))
|
||||
|
||||
from services.audit import log_action
|
||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||
@@ -876,16 +900,15 @@ def get_billing():
|
||||
@config_bp.route('/billing/upgrade', methods=['POST'])
|
||||
@require_auth('config.edit')
|
||||
def upgrade_billing():
|
||||
"""Upgrade tenant plan."""
|
||||
from services.billing import upgrade_plan
|
||||
data = request.get_json() or {}
|
||||
new_plan = data.get('plan')
|
||||
if not new_plan:
|
||||
return jsonify({'error': 'plan required'}), 400
|
||||
result = upgrade_plan(g.tenant_id, new_plan)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
"""Plan changes are handled by Nexus support, never self-service.
|
||||
|
||||
This endpoint previously let any tenant owner upgrade themselves to
|
||||
`enterprise` (unlimited) for free — a direct revenue leak. It now
|
||||
refuses every change; plans are managed from the master DB / admin panel.
|
||||
"""
|
||||
return jsonify({
|
||||
'error': 'Los cambios de plan los gestiona Nexus. Contacta a soporte para actualizar tu plan.'
|
||||
}), 403
|
||||
|
||||
|
||||
# ─── Vehicle Compatibility Source ────────────────────
|
||||
@@ -1000,6 +1023,49 @@ def update_allowed_brands():
|
||||
|
||||
# ─── WhatsApp Configuration ────────────────────────────────────────────────
|
||||
|
||||
|
||||
INVOICE_TOGGLE_KEYS = {
|
||||
'invoice_auto': 'invoice_auto',
|
||||
'invoice_email': 'invoice_email',
|
||||
'invoice_global': 'invoice_global',
|
||||
}
|
||||
|
||||
|
||||
@config_bp.route('/invoice-settings', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_invoice_settings():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
|
||||
(list(INVOICE_TOGGLE_KEYS.values()),))
|
||||
rows = {r[0]: r[1] for r in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'invoice_auto': rows.get('invoice_auto', 'false').lower() == 'true',
|
||||
'invoice_email': rows.get('invoice_email', 'false').lower() == 'true',
|
||||
'invoice_global': rows.get('invoice_global', 'false').lower() == 'true',
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/invoice-settings', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_invoice_settings():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
for json_key, cfg_key in INVOICE_TOGGLE_KEYS.items():
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (cfg_key, 'true' if data.get(json_key) else 'false'))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
@config_bp.route('/whatsapp', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_whatsapp_config():
|
||||
@@ -1016,6 +1082,14 @@ def get_whatsapp_config():
|
||||
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
||||
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||
'phone_number': rows.get('whatsapp_phone_number', ''),
|
||||
'provider': rows.get('whatsapp_provider', 'baileys'),
|
||||
'phone_number_id': rows.get('wa_phone_number_id', ''),
|
||||
'has_access_token': bool(rows.get('wa_access_token')),
|
||||
'has_app_secret': bool(rows.get('wa_app_secret')),
|
||||
'verify_token': rows.get('wa_verify_token', ''),
|
||||
'api_base': rows.get('wa_api_base', ''),
|
||||
'has_api_key': bool(rows.get('wa_api_key')),
|
||||
'public_base': rows.get('wa_public_base', ''),
|
||||
})
|
||||
|
||||
|
||||
@@ -1032,7 +1106,18 @@ def update_whatsapp_config():
|
||||
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
||||
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
||||
'whatsapp_phone_number': data.get('phone_number', ''),
|
||||
'whatsapp_provider': data.get('provider', 'baileys'),
|
||||
'wa_phone_number_id': data.get('phone_number_id', ''),
|
||||
'wa_verify_token': data.get('verify_token', ''),
|
||||
'wa_api_base': data.get('api_base', ''),
|
||||
'wa_public_base': data.get('public_base', ''),
|
||||
}
|
||||
if data.get('api_key'):
|
||||
settings['wa_api_key'] = data['api_key']
|
||||
if data.get('access_token'):
|
||||
settings['wa_access_token'] = data['access_token']
|
||||
if data.get('app_secret'):
|
||||
settings['wa_app_secret'] = data['app_secret']
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
@@ -1047,26 +1132,151 @@ def update_whatsapp_config():
|
||||
return jsonify({'message': 'WhatsApp configuration updated'})
|
||||
|
||||
|
||||
# ─── Contactos de alerta (POR NEGOCIO) ─────────────────────────────────────
|
||||
# Los contactos que reciben alertas por WhatsApp son POR NEGOCIO: cada tenant
|
||||
# configura sus propios números en su `tenant_config` (`alert_phones`,
|
||||
# `alert_whatsapp_enabled`). No hay ningún teléfono global obligatorio; si un
|
||||
# negocio no configura nada, sus alertas simplemente no se envían (no-op, sin
|
||||
# error). El webhook de Alertmanager (`pos/app.py`) lee estas mismas claves.
|
||||
|
||||
# `+` opcional seguido de 10-15 dígitos (formato internacional).
|
||||
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
|
||||
|
||||
|
||||
def _normalize_alert_phones(raw):
|
||||
"""Normaliza contactos separados por comas.
|
||||
|
||||
Acepta una cadena ("+5215512345678, 5215587654321") o una lista/tupla.
|
||||
Recorta espacios, valida cada número (`+` opcional + 10-15 dígitos) y
|
||||
descarta el resto. Devuelve la tupla (validos, descartados).
|
||||
"""
|
||||
if isinstance(raw, (list, tuple)):
|
||||
chunks = []
|
||||
for item in raw:
|
||||
chunks.extend(str(item).split(','))
|
||||
else:
|
||||
chunks = str(raw or '').split(',')
|
||||
|
||||
valid, dropped = [], []
|
||||
for chunk in chunks:
|
||||
phone = chunk.strip()
|
||||
if not phone:
|
||||
continue
|
||||
if _ALERT_PHONE_RE.match(phone):
|
||||
if phone not in valid:
|
||||
valid.append(phone)
|
||||
else:
|
||||
dropped.append(phone)
|
||||
return valid, dropped
|
||||
|
||||
|
||||
@config_bp.route('/alert-contacts', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_alert_contacts():
|
||||
"""Contactos de alerta por WhatsApp de ESTE negocio (tenant).
|
||||
|
||||
Son por negocio, nunca globales. `alert_phones` se devuelve como lista
|
||||
normalizada; `alert_whatsapp_enabled` indica si el reenvío está activado.
|
||||
Si el negocio no ha configurado nada, devuelve lista vacía y `false`
|
||||
(las alertas serán no-op). `inherited_global` siempre es `false`: no se
|
||||
hereda ningún teléfono global.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT key, value FROM tenant_config "
|
||||
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
|
||||
)
|
||||
rows = {r[0]: r[1] for r in cur.fetchall()}
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
phones, _ = _normalize_alert_phones(rows.get('alert_phones'))
|
||||
return jsonify({
|
||||
'alert_phones': phones,
|
||||
'alert_whatsapp_enabled': str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true',
|
||||
'inherited_global': False,
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/alert-contacts', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_alert_contacts():
|
||||
"""Guarda los contactos de alerta por WhatsApp de ESTE negocio.
|
||||
|
||||
Normaliza `alert_phones` (separa por comas, recorta y descarta basura:
|
||||
solo se aceptan `+` opcional + 10-15 dígitos) y guarda
|
||||
`alert_whatsapp_enabled` como booleano. Un payload vacío es válido y deja
|
||||
al negocio sin contactos (alertas no-op). Devuelve lo guardado.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON object required'}), 400
|
||||
|
||||
phones, dropped = _normalize_alert_phones(data.get('alert_phones'))
|
||||
enabled = bool(data.get('alert_whatsapp_enabled'))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('alert_phones', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (','.join(phones),))
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('alert_whatsapp_enabled', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('true' if enabled else 'false',))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if dropped:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"alert-contacts: %d contacto(s) descartado(s) para tenant %s",
|
||||
len(dropped), g.tenant_id,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'alert_phones': phones,
|
||||
'alert_whatsapp_enabled': enabled,
|
||||
'inherited_global': False,
|
||||
'dropped': dropped,
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def _bool(key):
|
||||
return rows.get(key, 'true').lower() == 'true'
|
||||
def _bool(name):
|
||||
# Unificado 2026-10-11: el POS escribe 'module_X' y el dashboard 'X_enabled';
|
||||
# aceptamos ambos para que el toggle se refleje en las dos capas.
|
||||
v = rows.get('module_' + name)
|
||||
if v is None:
|
||||
v = rows.get(name + '_enabled')
|
||||
return (v if v is not None else 'true').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'whatsapp': _bool('module_whatsapp'),
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
'catalog': _bool('module_catalog'),
|
||||
'workshop': _bool('module_workshop'),
|
||||
'whatsapp': _bool('whatsapp'),
|
||||
'marketplace': _bool('marketplace'),
|
||||
'meli': _bool('meli'),
|
||||
'catalog': _bool('catalog'),
|
||||
'workshop': _bool('workshop'),
|
||||
'yonke': _bool('yonke'),
|
||||
})
|
||||
|
||||
|
||||
@@ -1078,13 +1288,14 @@ def update_modules():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||
'module_meli': 'true' if data.get('meli') else 'false',
|
||||
'module_catalog': 'true' if data.get('catalog') else 'false',
|
||||
'module_workshop': 'true' if data.get('workshop') else 'false',
|
||||
}
|
||||
# Doble escritura: 'module_X' (POS) + 'X_enabled' (dashboard/consumidores).
|
||||
# Solo se tocan las claves presentes en el payload (no se pisan las demás).
|
||||
settings = {}
|
||||
for name in ('whatsapp', 'marketplace', 'meli', 'catalog', 'workshop', 'yonke'):
|
||||
if name in data:
|
||||
val = 'true' if data.get(name) else 'false'
|
||||
settings['module_' + name] = val
|
||||
settings[name + '_enabled'] = val
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
@@ -1222,6 +1433,7 @@ RECEIPT_CONFIG_KEYS = [
|
||||
'receipt_show_payment_details',
|
||||
'receipt_show_employee',
|
||||
'receipt_paper_width',
|
||||
'receipt_auto_print',
|
||||
]
|
||||
|
||||
|
||||
@@ -1258,6 +1470,7 @@ def get_receipt_config():
|
||||
'show_phone': _bool('receipt_show_phone', False),
|
||||
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
|
||||
'show_payment_details': _bool('receipt_show_payment_details', True),
|
||||
'auto_print': _bool('receipt_auto_print', False),
|
||||
'show_employee': _bool('receipt_show_employee', False),
|
||||
'paper_width': rows.get('receipt_paper_width', '80') or '80',
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||
|
||||
|
||||
@@ -155,6 +158,14 @@ def get_customer(customer_id):
|
||||
|
||||
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
||||
|
||||
# Credit risk score (A9). Never block customer detail on scoring errors.
|
||||
try:
|
||||
from services.credit_scoring import score_customer
|
||||
customer['credit_score'] = score_customer(conn, customer_id)
|
||||
except Exception as exc:
|
||||
logger.warning('credit score for customer %s failed: %s', customer_id, exc)
|
||||
customer['credit_score'] = None
|
||||
|
||||
# Recent purchases (last 20)
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
||||
@@ -199,6 +210,28 @@ def get_customer(customer_id):
|
||||
return jsonify(customer)
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/credit-score', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_customer_credit_score(customer_id):
|
||||
"""Credit risk score for a customer (A9).
|
||||
|
||||
Returns {score, tier, recommended_limit, reasons, data_points, ...}.
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
from services.credit_scoring import score_customer
|
||||
return jsonify(score_customer(conn, customer_id))
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error('credit-score for customer %s failed: %s', customer_id, e)
|
||||
return jsonify({'error': 'Could not compute credit score'}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_customer_purchases(customer_id):
|
||||
@@ -624,6 +657,24 @@ def record_customer_payment(customer_id):
|
||||
new_value={'credit_balance': new_balance, 'payment': amount})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): Cobro de credito
|
||||
# Debit Caja/Bancos, Credit Clientes
|
||||
try:
|
||||
from services.accounting_engine import record_credit_payment_entry
|
||||
record_credit_payment_entry(conn, {
|
||||
'customer_id': customer_id,
|
||||
'customer_name': cust[0],
|
||||
'amount': amount,
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logger.error(
|
||||
f'Accounting entry for customer payment #{payment_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
|
||||
@@ -7,7 +7,7 @@ import subprocess
|
||||
import socket
|
||||
from flask import Blueprint, request, jsonify
|
||||
from config import INTERNAL_API_KEY
|
||||
from tenant_db import get_master_conn, get_tenant_conn
|
||||
from tenant_db import get_master_conn, get_tenant_conn, get_tenant_conn_by_dbname
|
||||
|
||||
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@ import json
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.reorder_engine import (
|
||||
generate_alerts, list_alerts, acknowledge_alert, resolve_alert,
|
||||
suggest_po_from_alerts,
|
||||
)
|
||||
from services.inventory_engine import (
|
||||
get_stock, get_stock_bulk, record_purchase, record_return,
|
||||
record_adjustment, record_transfer, record_initial,
|
||||
@@ -65,14 +69,275 @@ def _to_int(val, default=0):
|
||||
return default
|
||||
|
||||
|
||||
# ─── Vehicle resolution (tablas de vehículos viven en la MAESTRA) ──────────
|
||||
_MYE_LOOKUP_CACHE = {}
|
||||
_MYE_TABLES_MISSING = {'flag': False}
|
||||
|
||||
|
||||
def _resolve_mye_master(make, model, year):
|
||||
"""Resuelve model_year_engine_id contra la BD maestra; None si no existe.
|
||||
|
||||
`brands`/`models`/`years`/`model_year_engine` sólo existen en la maestra:
|
||||
consultarlas en la conexión del tenant lanzaba UndefinedTable y devolvía
|
||||
500 en todo el bulk-import cuando el CSV traía make/model/year.
|
||||
"""
|
||||
key = (make.strip().lower(), model.strip().lower(), int(year))
|
||||
if key in _MYE_LOOKUP_CACHE:
|
||||
return _MYE_LOOKUP_CACHE[key]
|
||||
if _MYE_TABLES_MISSING['flag']:
|
||||
return None
|
||||
|
||||
result = None
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
try:
|
||||
mcur = mconn.cursor()
|
||||
try:
|
||||
mcur.execute("""
|
||||
SELECT mye.id_mye FROM model_year_engine mye
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
JOIN brands b ON b.id_brand = m.brand_id
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE LOWER(b.name_brand) = LOWER(%s)
|
||||
AND LOWER(m.name_model) = LOWER(%s)
|
||||
AND y.year_car = %s
|
||||
LIMIT 1
|
||||
""", (make, model, year))
|
||||
row = mcur.fetchone()
|
||||
result = row[0] if row else None
|
||||
finally:
|
||||
mcur.close()
|
||||
finally:
|
||||
mconn.close()
|
||||
except Exception as e:
|
||||
try:
|
||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
||||
missing = isinstance(e, (UndefinedTable, UndefinedColumn))
|
||||
except Exception:
|
||||
missing = 'does not exist' in str(e)
|
||||
if missing:
|
||||
# Esquema ausente: no se reintenta; la compat se guarda como texto.
|
||||
_MYE_TABLES_MISSING['flag'] = True
|
||||
return None
|
||||
|
||||
_MYE_LOOKUP_CACHE[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# ─── AI Classification ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET', 'POST'])
|
||||
@require_auth('inventory.create')
|
||||
def classify_part_endpoint(part_number):
|
||||
"""Ask AI to identify a part by its OEM number."""
|
||||
"""Ask AI to identify a part by its OEM number and persist its category.
|
||||
|
||||
In addition to the raw LLM result ({name, brand, vehicle, category}) this
|
||||
now resolves the returned category to a tenant ``categories.id`` and stores
|
||||
it in ``inventory.category_id`` when the part exists in the tenant catalog.
|
||||
"""
|
||||
from services.ai_chat import classify_part
|
||||
from services.product_categorizer import CategoryResolver, map_llm_category
|
||||
|
||||
result = classify_part(part_number)
|
||||
|
||||
category_id = None
|
||||
category_name = None
|
||||
persisted = 0
|
||||
raw_cat = (result or {}).get('category') if isinstance(result, dict) else None
|
||||
if raw_cat:
|
||||
canonical = map_llm_category(raw_cat)
|
||||
if canonical:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
resolver = CategoryResolver(conn, create=True)
|
||||
category_id, category_name = resolver.resolve(canonical)
|
||||
if category_id is not None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE inventory SET category_id = %s "
|
||||
"WHERE part_number = %s AND is_active = true "
|
||||
"AND (category_id IS NULL OR category_id <> %s)",
|
||||
(category_id, part_number, category_id),
|
||||
)
|
||||
persisted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if isinstance(result, dict):
|
||||
result['category_id'] = category_id
|
||||
result['category_name'] = category_name
|
||||
result['persisted'] = persisted
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@inventory_bp.route('/categorize', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def categorize_inventory_endpoint():
|
||||
"""Auto-categorize inventory items and persist ``category_id``.
|
||||
|
||||
Body options:
|
||||
{"items": [{"part_number", "name", "brand"}, ...]} # explicit list
|
||||
{"all": true, "limit": N, "only_uncategorized": true} # batch over catalog
|
||||
Optional: use_llm (LLM fallback per item), create_categories (default true).
|
||||
"""
|
||||
from services.product_categorizer import categorize_items, categorize_inventory
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
use_llm = bool(data.get('use_llm'))
|
||||
create_categories = bool(data.get('create_categories', True))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
if data.get('all'):
|
||||
limit = min(int(data.get('limit', 200) or 200), 5000)
|
||||
summary = categorize_inventory(
|
||||
conn, limit=limit,
|
||||
only_uncategorized=bool(data.get('only_uncategorized', True)),
|
||||
use_llm=use_llm, persist=True,
|
||||
create_categories=create_categories,
|
||||
offset=int(data.get('offset', 0) or 0),
|
||||
after_id=data.get('after_id'),
|
||||
)
|
||||
return jsonify({'mode': 'all', **summary})
|
||||
|
||||
items = data.get('items')
|
||||
if not items or not isinstance(items, list):
|
||||
return jsonify({'error': 'items[] o all:true requerido'}), 400
|
||||
summary = categorize_items(
|
||||
conn, items[:2000], use_llm=use_llm, persist=True,
|
||||
create_categories=create_categories,
|
||||
)
|
||||
return jsonify({'mode': 'items', **summary})
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/match-catalog', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def match_catalog_endpoint():
|
||||
"""Match inventory SKUs against the supplier catalog.
|
||||
|
||||
Populates ``inventory.catalog_part_id`` and registers matched supplier SKUs
|
||||
as ``inventory_sku_aliases``. Body options: limit, offset, only_unmatched,
|
||||
create_aliases, use_names, name_threshold, apply(false=dry run), after_id.
|
||||
"""
|
||||
from services.sku_matcher import match_inventory, count_links
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
limit = data.get('limit')
|
||||
if limit is not None:
|
||||
limit = min(int(limit), 50000)
|
||||
|
||||
tenant_conn = get_tenant_conn(g.tenant_id)
|
||||
master_conn = get_master_conn()
|
||||
try:
|
||||
before = count_links(tenant_conn)
|
||||
summary = match_inventory(
|
||||
tenant_conn, master_conn, limit=limit,
|
||||
offset=int(data.get('offset', 0) or 0),
|
||||
only_unmatched=bool(data.get('only_unmatched', True)),
|
||||
create_aliases=bool(data.get('create_aliases', True)),
|
||||
use_names=bool(data.get('use_names', True)),
|
||||
name_threshold=float(data.get('name_threshold', 0.82)),
|
||||
apply=bool(data.get('apply', True)),
|
||||
supplier=data.get('supplier'),
|
||||
after_id=data.get('after_id'),
|
||||
)
|
||||
after = count_links(tenant_conn)
|
||||
summary['before'] = {'total': before[0], 'linked': before[1]}
|
||||
summary['after'] = {'total': after[0], 'linked': after[1]}
|
||||
return jsonify(summary)
|
||||
except Exception as e:
|
||||
try:
|
||||
tenant_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
tenant_conn.close()
|
||||
master_conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/supplier-invoice/parse', methods=['POST'])
|
||||
@require_auth('inventory.create')
|
||||
def supplier_invoice_parse_endpoint():
|
||||
"""OCR/parse a supplier invoice (image or PDF) into structured lines.
|
||||
|
||||
Accepts multipart ``file`` or JSON ``{file_base64, filename}``. Returns the
|
||||
detected lines plus an inventory/catalog match for each part number. Does
|
||||
NOT create stock — the front-end confirms before a purchase is recorded.
|
||||
"""
|
||||
import base64 as _b64
|
||||
from services.invoice_ocr import parse_invoice
|
||||
|
||||
data = None
|
||||
filename = None
|
||||
content_type = None
|
||||
f = request.files.get('file')
|
||||
if f:
|
||||
data = f.read()
|
||||
filename = f.filename
|
||||
content_type = f.content_type
|
||||
else:
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get('file_base64')
|
||||
if raw:
|
||||
if raw.startswith('data:'):
|
||||
raw = raw.split(',', 1)[1]
|
||||
try:
|
||||
data = _b64.b64decode(raw)
|
||||
except Exception:
|
||||
return jsonify({'error': 'file_base64 inválido'}), 400
|
||||
filename = body.get('filename')
|
||||
if not data:
|
||||
return jsonify({'error': 'file (multipart) o file_base64 requerido'}), 400
|
||||
|
||||
use_vision = request.args.get('vision', '1').lower() not in ('0', 'false', 'no')
|
||||
result = parse_invoice(data, filename=filename, content_type=content_type,
|
||||
use_vision=use_vision)
|
||||
|
||||
# Enrich each line with the tenant inventory item it points at.
|
||||
norm = {}
|
||||
for ln in result.get('lines', []):
|
||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
||||
if pn:
|
||||
norm[pn] = None
|
||||
if norm:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, UPPER(REPLACE(part_number, ' ', '')) AS npn, name, cost "
|
||||
"FROM inventory WHERE is_active = true AND "
|
||||
"UPPER(REPLACE(part_number, ' ', '')) = ANY(%s)",
|
||||
(list(norm.keys()),),
|
||||
)
|
||||
for inv_id, npn, name, cost in cur.fetchall():
|
||||
norm[npn] = {'inventory_id': inv_id, 'name': name,
|
||||
'cost': float(cost) if cost is not None else None}
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
for ln in result.get('lines', []):
|
||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
||||
hit = norm.get(pn)
|
||||
ln['matched_inventory_id'] = hit['inventory_id'] if hit else None
|
||||
if hit and not ln.get('name'):
|
||||
ln['name'] = hit['name']
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -414,6 +679,119 @@ def create_item():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@inventory_bp.route('/export', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def export_inventory():
|
||||
"""Export the inventory as CSV, compatible with the bulk importer.
|
||||
|
||||
Query params:
|
||||
branch_id: optional. When given, the stock (and location) columns reflect
|
||||
only that branch, and a branch_id column is appended so the
|
||||
file can be re-imported into the same branch.
|
||||
|
||||
Columns: part_number, sku_secondary, name, brand, category, cost, price,
|
||||
price_2, price_3, stock, location, description, barcode, unit[, branch_id]
|
||||
- without branch_id, stock is the total across all branches.
|
||||
- cost is blank unless the user has the inventory.view_cost permission.
|
||||
Streamed in chunks to support large catalogs.
|
||||
"""
|
||||
include_cost = has_permission('inventory.view_cost')
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
branch_name = None
|
||||
if branch_id:
|
||||
cur.execute("SELECT name FROM branches WHERE id = %s", (branch_id,))
|
||||
brow = cur.fetchone()
|
||||
if not brow:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Branch {branch_id} not found'}), 400
|
||||
branch_name = brow[0]
|
||||
cur.execute("""
|
||||
SELECT i.part_number,
|
||||
(SELECT a.sku FROM inventory_sku_aliases a
|
||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
||||
i.name, i.brand, c.name AS category, i.cost,
|
||||
i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(ist.stock, 0) AS stock,
|
||||
COALESCE(ist.location, i.location) AS location,
|
||||
i.description, i.barcode, i.unit
|
||||
FROM inventory i
|
||||
LEFT JOIN categories c ON c.id = i.category_id
|
||||
LEFT JOIN inventory_stock ist
|
||||
ON ist.inventory_id = i.id AND ist.branch_id = %s
|
||||
WHERE i.is_active = true
|
||||
ORDER BY i.part_number
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT i.part_number,
|
||||
(SELECT a.sku FROM inventory_sku_aliases a
|
||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
||||
i.name, i.brand, c.name AS category, i.cost,
|
||||
i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(st.total_stock, 0) AS stock,
|
||||
i.location, i.description, i.barcode, i.unit
|
||||
FROM inventory i
|
||||
LEFT JOIN categories c ON c.id = i.category_id
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS total_stock
|
||||
FROM inventory_stock GROUP BY inventory_id
|
||||
) st ON st.inventory_id = i.id
|
||||
WHERE i.is_active = true
|
||||
ORDER BY i.part_number
|
||||
""")
|
||||
|
||||
def generate():
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
try:
|
||||
header = ['part_number', 'sku_secondary', 'name', 'brand',
|
||||
'category', 'cost', 'price', 'price_2', 'price_3',
|
||||
'stock', 'location', 'description', 'barcode', 'unit']
|
||||
if branch_id:
|
||||
header.append('branch_id')
|
||||
writer.writerow(header)
|
||||
# BOM so Excel opens UTF-8 correctly
|
||||
yield '\ufeff' + output.getvalue()
|
||||
output.seek(0); output.truncate(0)
|
||||
while True:
|
||||
rows = cur.fetchmany(1000)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
row = [
|
||||
r[0] or '', r[1] or '', r[2] or '', r[3] or '', r[4] or '',
|
||||
f'{float(r[5]):.2f}' if include_cost and r[5] is not None else '',
|
||||
f'{float(r[6]):.2f}' if r[6] is not None else '0.00',
|
||||
f'{float(r[7]):.2f}' if r[7] is not None else '',
|
||||
f'{float(r[8]):.2f}' if r[8] is not None else '',
|
||||
int(r[9]) if r[9] is not None else 0,
|
||||
r[10] or '', r[11] or '', r[12] or '', r[13] or '',
|
||||
]
|
||||
if branch_id:
|
||||
row.append(branch_id)
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.seek(0); output.truncate(0)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if branch_id:
|
||||
safe_branch = ''.join(ch if ch.isalnum() else '_' for ch in (branch_name or str(branch_id)))
|
||||
filename = f"inventario_{safe_branch}_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
||||
else:
|
||||
filename = f"inventario_completo_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/items/bulk-import', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def bulk_import_items():
|
||||
@@ -422,8 +800,12 @@ def bulk_import_items():
|
||||
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
||||
Headers:
|
||||
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
||||
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
||||
'skip' ignores missing compat; 'reject' requires all compat.
|
||||
X-Import-Strategy: 'skip' (default) ignores missing compat and never
|
||||
calls QWEN; 'qwen' auto-generates missing compat via
|
||||
QWEN (opt-in: 20.7M noisy fitment rows so far);
|
||||
'reject' requires all compat.
|
||||
X-Import-Stock: 'add' (default) sums stock to existing items;
|
||||
'skip' only updates data/prices of existing items without touching stock.
|
||||
Expected CSV columns (case-insensitive):
|
||||
sku/part_number, name, brand, price, stock, cost,
|
||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
||||
@@ -434,7 +816,8 @@ def bulk_import_items():
|
||||
import services.inventory_vehicle_compat as ivc_service
|
||||
|
||||
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'skip').lower()
|
||||
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
|
||||
errors = []
|
||||
warnings = []
|
||||
created_ids = []
|
||||
@@ -594,8 +977,8 @@ def bulk_import_items():
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
if stock > 0:
|
||||
# Record stock adjustment for existing item if stock > 0 (unless skipped)
|
||||
if stock > 0 and stock_mode != 'skip':
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
else:
|
||||
# Generate barcode for new item
|
||||
@@ -648,24 +1031,9 @@ def bulk_import_items():
|
||||
year = _to_int(year_str, None)
|
||||
mye_id = None
|
||||
if make and model and year:
|
||||
# Try exact match against model_year_engine
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mye.id FROM model_year_engine mye
|
||||
JOIN models m ON m.id = mye.model_id
|
||||
JOIN brands b ON b.id = m.brand_id
|
||||
JOIN years y ON y.id = mye.year_id
|
||||
WHERE LOWER(b.name) = LOWER(%s)
|
||||
AND LOWER(m.name) = LOWER(%s)
|
||||
AND y.year = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(make, model, year)
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
mye_id = r[0]
|
||||
else:
|
||||
# Resolver contra la BD maestra (no la del tenant).
|
||||
mye_id = _resolve_mye_master(make, model, year)
|
||||
if mye_id is None:
|
||||
warnings.append(
|
||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||
'saving as text-only compatibility.'
|
||||
@@ -1078,6 +1446,251 @@ def api_adjustment():
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
|
||||
|
||||
@inventory_bp.route('/items/lookup', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def lookup_item_by_code():
|
||||
"""Exact lookup by barcode / part_number / SKU alias (for scanner flows)."""
|
||||
code = (request.args.get('code') or '').strip()
|
||||
if not code:
|
||||
return jsonify({'error': 'code requerido'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
norm = code.upper().replace(' ', '')
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.barcode,
|
||||
COALESCE(s.stock, 0) AS stock, i.price_1
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true AND (
|
||||
UPPER(i.barcode) = %s
|
||||
OR UPPER(REPLACE(i.part_number, ' ', '')) = %s
|
||||
OR i.id IN (
|
||||
SELECT a.inventory_id FROM inventory_sku_aliases a
|
||||
WHERE UPPER(REPLACE(a.sku, ' ', '')) = %s))
|
||||
LIMIT 1
|
||||
""", (code, norm, norm))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'found': False}), 404
|
||||
return jsonify({'found': True, 'item': {
|
||||
'id': row[0], 'part_number': row[1], 'name': row[2],
|
||||
'barcode': row[3], 'stock': int(row[4] or 0),
|
||||
'price_1': float(row[5] or 0),
|
||||
}})
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── Transfer documents (in-transit tracking) ──────────────────────────────
|
||||
|
||||
@inventory_bp.route('/transfer-docs', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def list_transfer_docs():
|
||||
"""List transfer documents. Query: status, page, per_page."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
status = request.args.get('status')
|
||||
where = "WHERE t.status = %s" if status else ""
|
||||
params = [status] if status else []
|
||||
cur.execute(f"""
|
||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name,
|
||||
t.status, t.notes, t.created_at, t.sent_at, t.received_at,
|
||||
COALESCE(SUM(l.quantity), 0) AS total_qty
|
||||
FROM inventory_transfers t
|
||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
||||
LEFT JOIN inventory_transfer_lines l ON l.transfer_id = t.id
|
||||
{where}
|
||||
GROUP BY t.id, b1.name, b2.name
|
||||
ORDER BY t.id DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
docs = [{
|
||||
'id': r[0], 'from_branch_id': r[1], 'from_name': r[2],
|
||||
'to_branch_id': r[3], 'to_name': r[4], 'status': r[5],
|
||||
'notes': r[6], 'created_at': str(r[7]), 'sent_at': str(r[8]),
|
||||
'received_at': str(r[9]), 'total_qty': int(r[10] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': docs})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def create_transfer_doc():
|
||||
"""Create a draft transfer document.
|
||||
|
||||
Body: { to_branch_id, items: [{inventory_id, quantity}], notes }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
to_branch = data.get('to_branch_id')
|
||||
items = data.get('items', [])
|
||||
if not to_branch or not items:
|
||||
return jsonify({'error': 'to_branch_id e items son requeridos'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
from_branch = data.get('from_branch_id') or g.branch_id
|
||||
if not from_branch:
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
from_branch = cur.fetchone()[0]
|
||||
if int(to_branch) == int(from_branch):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Origen y destino no pueden ser la misma sucursal'}), 400
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_transfers (from_branch_id, to_branch_id, status, notes, employee_id)
|
||||
VALUES (%s, %s, 'draft', %s, %s) RETURNING id
|
||||
""", (from_branch, to_branch, data.get('notes'), getattr(g, 'employee_id', None)))
|
||||
doc_id = cur.fetchone()[0]
|
||||
for it in items:
|
||||
qty = int(it.get('quantity', 0) or 0)
|
||||
if qty <= 0 or not it.get('inventory_id'):
|
||||
continue
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_transfer_lines (transfer_id, inventory_id, quantity)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (doc_id, it['inventory_id'], qty))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'id': doc_id, 'status': 'draft'}), 201
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_transfer_doc(doc_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name, t.status,
|
||||
t.notes, t.created_at, t.sent_at, t.received_at
|
||||
FROM inventory_transfers t
|
||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
||||
WHERE t.id = %s
|
||||
""", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
doc = {
|
||||
'id': row[0], 'from_branch_id': row[1], 'from_name': row[2],
|
||||
'to_branch_id': row[3], 'to_name': row[4], 'status': row[5],
|
||||
'notes': row[6], 'created_at': str(row[7]), 'sent_at': str(row[8]),
|
||||
'received_at': str(row[9]), 'lines': [],
|
||||
}
|
||||
cur.execute("""
|
||||
SELECT l.id, l.inventory_id, i.part_number, i.name, l.quantity,
|
||||
COALESCE(l.qty_received, 0), COALESCE(s.stock, 0)
|
||||
FROM inventory_transfer_lines l
|
||||
JOIN inventory i ON i.id = l.inventory_id
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
WHERE branch_id = %s GROUP BY inventory_id
|
||||
) s ON s.inventory_id = l.inventory_id
|
||||
WHERE l.transfer_id = %s ORDER BY l.id
|
||||
""", (doc['to_branch_id'], doc_id))
|
||||
doc['lines'] = [{
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
||||
'quantity': int(r[4]), 'qty_received': int(r[5] or 0), 'dest_stock': int(r[6] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(doc)
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/send', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def send_transfer_doc(doc_id):
|
||||
"""Mark draft as sent (goods leave origin: stock -qty in origin branch)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT from_branch_id, to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
if row[2] != 'draft':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Solo borradores pueden enviarse (estado: {row[2]})'}), 400
|
||||
cur.execute("SELECT inventory_id, quantity FROM inventory_transfer_lines WHERE transfer_id = %s", (doc_id,))
|
||||
lines = cur.fetchall()
|
||||
for inv_id, qty in lines:
|
||||
record_transfer(conn, inv_id, row[0], row[1], qty,
|
||||
notes=f'Transferencia #{doc_id} enviada')
|
||||
cur.execute("""
|
||||
UPDATE inventory_transfers SET status = 'sent', sent_at = NOW() WHERE id = %s
|
||||
""", (doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'status': 'sent'})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/receive', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def receive_transfer_doc(doc_id):
|
||||
"""Confirm receipt at destination. Adjusts destination stock to the
|
||||
received quantities (discrepancies become adjustments)."""
|
||||
data = request.get_json() or {}
|
||||
received = {int(k): int(v) for k, v in (data.get('received') or {}).items()}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
if row[1] != 'sent':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Solo transferencias enviadas pueden recibirse (estado: {row[1]})'}), 400
|
||||
to_branch = row[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT l.id, l.inventory_id, l.quantity FROM inventory_transfer_lines l
|
||||
WHERE l.transfer_id = %s
|
||||
""", (doc_id,))
|
||||
lines = cur.fetchall()
|
||||
differences = 0
|
||||
for line_id, inv_id, qty in lines:
|
||||
rec = received.get(line_id)
|
||||
qty_rec = rec if rec is not None else qty
|
||||
cur.execute("UPDATE inventory_transfer_lines SET qty_received = %s WHERE id = %s",
|
||||
(qty_rec, line_id))
|
||||
if qty_rec != qty:
|
||||
differences += 1
|
||||
record_adjustment(conn, inv_id, to_branch, qty_rec - qty,
|
||||
reason=f'Diferencia en recepción de transferencia #{doc_id}')
|
||||
cur.execute("""
|
||||
UPDATE inventory_transfers SET status = 'received', received_at = NOW() WHERE id = %s
|
||||
""", (doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'status': 'received', 'differences': differences})
|
||||
|
||||
|
||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/cancel', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def cancel_transfer_doc(doc_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE inventory_transfers SET status = 'cancelled' WHERE id = %s AND status = 'draft'",
|
||||
(doc_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@inventory_bp.route('/transfer', methods=['POST'])
|
||||
@require_auth('inventory.transfer')
|
||||
def api_transfer():
|
||||
|
||||
@@ -20,6 +20,7 @@ from services.cfdi_facturapi_builder import (
|
||||
from services.cfdi_queue import (
|
||||
cancel_cfdi,
|
||||
enqueue_cfdi,
|
||||
enqueue_pending_complements,
|
||||
get_queue_status,
|
||||
process_queue,
|
||||
retry_failed,
|
||||
@@ -62,7 +63,7 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||
SELECT rfc, razon_social, regimen_fiscal, cp, serie_cfdi
|
||||
FROM branches WHERE id = %s
|
||||
""",
|
||||
(branch_id,),
|
||||
@@ -84,7 +85,8 @@ def _get_sale_with_items(cur, sale_id):
|
||||
"""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at,
|
||||
currency, exchange_rate
|
||||
FROM sales WHERE id = %s
|
||||
""",
|
||||
(sale_id,),
|
||||
@@ -108,6 +110,8 @@ def _get_sale_with_items(cur, sale_id):
|
||||
"forma_pago_sat": row[11] or "01",
|
||||
"status": row[12],
|
||||
"created_at": str(row[13]),
|
||||
"currency": row[14] or "MXN",
|
||||
"exchange_rate": float(row[15]) if row[15] else None,
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
@@ -366,6 +370,44 @@ def trigger_process_queue():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@invoicing_bp.route("/complementos/sync", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def sync_complementos():
|
||||
"""Enqueue the missing payment complements (REP) and credit notes (egreso).
|
||||
|
||||
Payments on stamped PPD invoices become CFDI type "P" linked to the
|
||||
original UUID; returns of stamped sales become CFDI type "E". Already
|
||||
enqueued movements are not duplicated.
|
||||
|
||||
Body: {
|
||||
backfill: bool (default false) — reconcile the whole history,
|
||||
since: ISO date (optional) — reconcile movements created after it
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
backfill = bool(data.get("backfill"))
|
||||
since = data.get("since")
|
||||
|
||||
if since:
|
||||
try:
|
||||
datetime.fromisoformat(str(since))
|
||||
except ValueError:
|
||||
return jsonify({"error": "since must be an ISO 8601 date/datetime"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
try:
|
||||
result = enqueue_pending_complements(conn, since=since, backfill=backfill)
|
||||
conn.close()
|
||||
result["enqueued"] = len(result["pagos"]) + len(result["egresos"])
|
||||
return jsonify(result), 201
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── Cancel CFDI ────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,19 @@ Routes:
|
||||
Listings
|
||||
GET /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings/import-existing
|
||||
POST /pos/api/marketplace-ext/listings/backfill-inventory
|
||||
POST /pos/api/marketplace-ext/listings/<id>/sync
|
||||
POST /pos/api/marketplace-ext/listings/<id>/pause
|
||||
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||
DELETE /pos/api/marketplace-ext/listings/<id>
|
||||
|
||||
Questions
|
||||
GET /pos/api/marketplace-ext/questions
|
||||
POST /pos/api/marketplace-ext/questions/sync
|
||||
POST /pos/api/marketplace-ext/questions/<id>/answer
|
||||
GET /pos/api/marketplace-ext/questions/<id>/suggestion (AI, A7)
|
||||
|
||||
Orders
|
||||
GET /pos/api/marketplace-ext/orders
|
||||
GET /pos/api/marketplace-ext/orders/<id>
|
||||
@@ -62,10 +70,84 @@ def _require_meli_manage():
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(value, default):
|
||||
"""Parse an int from a query param, falling back to a default instead of 500."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_meli_config(tenant_id):
|
||||
"""Return the tenant's ML config dict, or None if the tenant is unknown."""
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return meli_svc.get_meli_config(conn)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _resolve_tenant_by_meli_user(meli_user_id):
|
||||
"""Find the tenant whose tenant_config.meli_user_id matches.
|
||||
|
||||
tenant_config lives in each tenant DB (not in master), so we iterate the
|
||||
active tenants from master and probe each one.
|
||||
"""
|
||||
if not meli_user_id:
|
||||
return None
|
||||
mconn = get_master_conn()
|
||||
try:
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT id FROM tenants WHERE is_active = true ORDER BY id")
|
||||
tenant_ids = [r[0] for r in mcur.fetchall()]
|
||||
mcur.close()
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
mconn.close()
|
||||
|
||||
target = str(meli_user_id)
|
||||
for tid in tenant_ids:
|
||||
try:
|
||||
conn = get_tenant_conn(tid)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM tenant_config WHERE key = 'meli_user_id' AND value = %s LIMIT 1",
|
||||
(target,),
|
||||
)
|
||||
found = cur.fetchone() is not None
|
||||
cur.close()
|
||||
if found:
|
||||
return tid
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _meli_auth_error(e):
|
||||
"""Respuesta 401 para MeliAuthError: propaga el detalle accionable
|
||||
(p. ej. credenciales de app rechazadas) en vez del mensaje genérico."""
|
||||
detail = str(e)
|
||||
if ('credenciales' in detail.lower() or 'refresh failed' in detail.lower()
|
||||
or 'invalid_client' in detail.lower()):
|
||||
return jsonify({'error': detail}), 401
|
||||
return jsonify({'error': 'La sesión de MercadoLibre expiró. Reconecta la cuenta en la pestaña de configuración.'}), 401
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_config():
|
||||
@@ -78,6 +160,7 @@ def get_config():
|
||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||
}
|
||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||
safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token"))
|
||||
return jsonify(safe)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -178,12 +261,17 @@ def connect_meli():
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
return jsonify({
|
||||
resp = {
|
||||
"ok": True,
|
||||
"user_id": user_id or user.get("id"),
|
||||
"nickname": user.get("nickname"),
|
||||
"site_id": user.get("site_id"),
|
||||
})
|
||||
}
|
||||
if not refresh_token:
|
||||
# The ML app lacks offline_access scope: the connection will die
|
||||
# in ~6h and cannot be auto-renewed. Warn so it can be fixed.
|
||||
resp["warning"] = "no_refresh_token"
|
||||
return jsonify(resp)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -214,13 +302,13 @@ def search_categories():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -234,16 +322,16 @@ def search_categories():
|
||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_listings():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -279,6 +367,7 @@ def create_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
@@ -309,6 +398,36 @@ def import_existing_listings():
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/backfill-inventory", methods=["POST"])
|
||||
@require_auth()
|
||||
def backfill_listing_inventory():
|
||||
"""Map already-imported listings to local inventory (populate inventory_id).
|
||||
|
||||
Body: {"dry_run": bool (default false), "limit": int|null,
|
||||
"only_unmapped": bool (default true)}.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.backfill_listing_inventory(
|
||||
conn,
|
||||
limit=_safe_int(data.get("limit"), None) if data.get("limit") else None,
|
||||
only_unmapped=bool(data.get("only_unmapped", True)),
|
||||
dry_run=bool(data.get("dry_run", False)),
|
||||
)
|
||||
return jsonify(result), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||
@require_auth()
|
||||
def inventory_check():
|
||||
@@ -338,7 +457,8 @@ def sync_stock_to_meli():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.process_meli_sync_queue(conn)
|
||||
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate.
|
||||
result = meli_svc.process_meli_sync_queue(conn, force=True)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -355,15 +475,41 @@ def category_attributes(category_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
attrs = svc.get_category_attributes(category_id)
|
||||
# Filter to required attributes only for the UI
|
||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||
return jsonify({"attributes": required, "all": attrs})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listing-prices", methods=["GET"])
|
||||
@require_auth()
|
||||
def listing_prices():
|
||||
"""Fee estimator: return MeLi fees per listing type for a price + category."""
|
||||
price = request.args.get("price", type=float)
|
||||
category_id = request.args.get("category_id", "")
|
||||
site_id = request.args.get("site_id", "MLM")
|
||||
if not price or price <= 0 or not category_id:
|
||||
return jsonify({"error": "price y category_id son requeridos"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg, conn)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
result = svc.get_listing_prices(site_id, price, category_id)
|
||||
return jsonify({"listing_prices": result})
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -400,6 +546,7 @@ def validate_listings():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -440,6 +587,8 @@ def create_listings_async():
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
chunk_size=_safe_int(data.get("chunk_size"), 10),
|
||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
||||
)
|
||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||
except Exception as e:
|
||||
@@ -452,7 +601,7 @@ def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from app import celery as celery_app
|
||||
from celery_app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
@@ -616,6 +765,34 @@ def answer_question(question_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/<int:question_id>/suggestion", methods=["GET"])
|
||||
@require_auth()
|
||||
def question_suggestion(question_id):
|
||||
"""AI intent classification + suggested answer for a buyer question.
|
||||
|
||||
Query param: ?use_llm=0 to skip the LLM fallback and use templates only.
|
||||
The answer is only a suggestion; auto-answering is opt-in per tenant via
|
||||
tenant_config `meli_auto_answer_questions`.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
use_llm = request.args.get("use_llm", "1").lower() not in ("0", "false", "no")
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.suggest_question_answer(conn, question_id, use_llm=use_llm)
|
||||
result["auto_answer_enabled"] = bool(
|
||||
meli_svc.get_meli_config(conn).get("meli_auto_answer_questions")
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -643,8 +820,8 @@ def sync_orders():
|
||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -673,11 +850,15 @@ def get_order(order_id):
|
||||
def convert_order(order_id):
|
||||
data = request.get_json() or {}
|
||||
register_id = data.get("register_id")
|
||||
# Mirrors the counter-sale "Facturar CFDI" checkbox; when omitted the sale
|
||||
# is only invoiced if the tenant has meli_auto_invoice = true.
|
||||
generate_cfdi = bool(data.get("generate_cfdi", False))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.convert_order_to_sale(
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id,
|
||||
generate_cfdi=generate_cfdi,
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -716,38 +897,35 @@ def update_order_status_route(order_id):
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
ML sends a lightweight payload with topic + resource URL. We resolve the
|
||||
tenant from ML's user_id, validate the X-Signature header when present, ack
|
||||
immediately and enqueue Celery to process the specific resource.
|
||||
"""
|
||||
raw_body = request.get_data()
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
topic = data.get("topic", "")
|
||||
resource = data.get("resource", "")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
# Resolve tenant by meli_user_id
|
||||
tenant_id = None
|
||||
if user_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute(
|
||||
"""
|
||||
SELECT t.id FROM tenants t
|
||||
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||
WHERE t.is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(user_id),),
|
||||
)
|
||||
row = mcur.fetchone()
|
||||
if row:
|
||||
tenant_id = row[0]
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# tenant_config lives in each tenant DB, not in master
|
||||
tenant_id = _resolve_tenant_by_meli_user(user_id)
|
||||
if not tenant_id:
|
||||
print(f"[ML Webhook] No tenant found for meli_user_id={user_id}")
|
||||
return jsonify({"error": "unknown tenant"}), 403
|
||||
|
||||
if tenant_id and topic:
|
||||
cfg = _get_meli_config(tenant_id) or {}
|
||||
secret = cfg.get("meli_client_secret")
|
||||
signature = request.headers.get("X-Signature")
|
||||
if signature:
|
||||
if not secret or not MeliService.validate_webhook_signature(secret, raw_body, signature):
|
||||
print(f"[ML Webhook] Invalid X-Signature for tenant {tenant_id}")
|
||||
return jsonify({"error": "invalid signature"}), 401
|
||||
else:
|
||||
# ML only signs notifications when signature validation is enabled on
|
||||
# the app; without the header there is nothing to verify.
|
||||
print(f"[ML Webhook] Notification without X-Signature (tenant {tenant_id}, topic={topic})")
|
||||
|
||||
if topic:
|
||||
try:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
|
||||
@@ -18,6 +18,11 @@ from services.pos_engine import (
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
from services.cross_sell import get_cross_sell
|
||||
from services.anomaly_detector import (
|
||||
detect_anomalies, DEFAULT_MIN_SAMPLES as _ANOM_MIN_SAMPLES,
|
||||
DEFAULT_Z as _ANOM_Z,
|
||||
)
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
@@ -41,6 +46,88 @@ def _tenant_allows_zero_price(conn):
|
||||
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
|
||||
|
||||
|
||||
def _int_arg(name, default=None, min_value=None, max_value=None):
|
||||
"""Parse an integer query-string argument safely.
|
||||
|
||||
Bad input (e.g. ?per_page=abc) falls back to the default instead of
|
||||
raising ValueError and returning a 500.
|
||||
"""
|
||||
raw = request.args.get(name)
|
||||
if raw is None or raw == '':
|
||||
value = default
|
||||
else:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if value is None:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
value = min_value
|
||||
if max_value is not None and value > max_value:
|
||||
value = max_value
|
||||
return value
|
||||
|
||||
|
||||
def _float_arg(name, default, min_value=None, max_value=None):
|
||||
"""Parse a float query-string argument safely (bad input → default)."""
|
||||
raw = request.args.get(name)
|
||||
try:
|
||||
value = float(raw) if raw not in (None, '') else default
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if value is None:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
value = min_value
|
||||
if max_value is not None and value > max_value:
|
||||
value = max_value
|
||||
return value
|
||||
|
||||
|
||||
def _validate_price_floor(conn, items):
|
||||
"""Raise ValueError if any line price is below item cost and the employee
|
||||
is not owner/admin. Prevents margin leaks via price editing."""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
cur = conn.cursor()
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
unit_price = float(item.get('unit_price', 0) or 0)
|
||||
if not inv_id or unit_price <= 0:
|
||||
continue
|
||||
cur.execute("SELECT part_number, cost FROM inventory WHERE id = %s", (inv_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[1] and unit_price < float(row[1]):
|
||||
cur.close()
|
||||
raise ValueError(
|
||||
f"Precio por debajo del costo en {row[0]} (${unit_price:,.2f} < ${float(row[1]):,.2f}). "
|
||||
"Solicita autorización de gerencia.")
|
||||
cur.close()
|
||||
|
||||
|
||||
def _validate_max_discount(conn, items):
|
||||
"""Raise ValueError if any line discount exceeds the employee's cap.
|
||||
|
||||
Mirrors the check done by pos_engine.process_sale so quotations and
|
||||
layaways cannot be used to bypass the discount limit.
|
||||
"""
|
||||
from flask import g as _g
|
||||
if getattr(_g, 'employee_role', None) in ('owner', 'admin'):
|
||||
return
|
||||
from services.pos_engine import get_max_discount_pct
|
||||
max_discount = get_max_discount_pct(conn)
|
||||
for item in items:
|
||||
discount_pct = float(item.get('discount_pct', 0) or 0)
|
||||
if discount_pct > max_discount:
|
||||
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
|
||||
raise ValueError(
|
||||
f"Descuento {discount_pct}% excede tu máximo permitido {max_discount}% "
|
||||
f"para {name}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_zero_price(conn, items):
|
||||
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
|
||||
if _tenant_allows_zero_price(conn):
|
||||
@@ -156,7 +243,38 @@ def create_sale():
|
||||
try:
|
||||
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
||||
_validate_zero_price(conn, data.get('items', []))
|
||||
_validate_price_floor(conn, data.get('items', []))
|
||||
|
||||
# Idempotencia offline: si el client_uuid ya existe, devolver esa venta
|
||||
client_uuid = (data.get('client_uuid') or '').strip() or None
|
||||
cur_iu = conn.cursor()
|
||||
if client_uuid:
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
if dup:
|
||||
cur_iu.execute("SELECT id, total FROM sales WHERE id = %s", (dup[0],))
|
||||
srow = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': srow[0], 'total': float(srow[1]),
|
||||
'duplicate': True,
|
||||
'message': 'Venta ya registrada (offline sync)'}), 200
|
||||
|
||||
sale = process_sale(conn, data)
|
||||
if client_uuid:
|
||||
try:
|
||||
cur_iu.execute("UPDATE sales SET client_uuid = %s WHERE id = %s",
|
||||
(client_uuid, sale['id']))
|
||||
except Exception:
|
||||
conn.rollback() # duplicate race: fetch existing
|
||||
cur_iu.execute("SELECT id FROM sales WHERE client_uuid = %s LIMIT 1", (client_uuid,))
|
||||
dup = cur_iu.fetchone()
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'id': dup[0], 'duplicate': True}), 200
|
||||
cur_iu.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -267,22 +385,22 @@ def list_sales():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
employee_id = _int_arg('employee_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
courier_id = _int_arg('courier_id')
|
||||
register_id = _int_arg('register_id')
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("s.created_at >= %s")
|
||||
@@ -292,10 +410,10 @@ def list_sales():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("s.employee_id = %s")
|
||||
params.append(int(employee_id))
|
||||
params.append(employee_id)
|
||||
if customer_id:
|
||||
where_clauses.append("s.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
@@ -310,10 +428,10 @@ def list_sales():
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
params.append(courier_id)
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
params.append(register_id)
|
||||
|
||||
# Default to current branch
|
||||
if g.branch_id:
|
||||
@@ -386,7 +504,7 @@ def recent_sales():
|
||||
|
||||
date_from = request.args.get('date_from') or str(date.today())
|
||||
date_to = request.args.get('date_to') or date_from
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
limit = _int_arg('limit', 10, min_value=1, max_value=50)
|
||||
|
||||
where_clauses = [
|
||||
"s.created_at >= %s",
|
||||
@@ -454,8 +572,8 @@ def list_historical_sales():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
@@ -616,6 +734,25 @@ def api_cancel_sale(sale_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = cancel_sale(conn, sale_id, reason)
|
||||
# Auto-cancel stamped CFDI (motivo 03: no se llevó a cabo la operación)
|
||||
try:
|
||||
from services.cfdi_queue import cancel_cfdi
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id FROM cfdi_queue WHERE sale_id = %s AND status = 'stamped' "
|
||||
"ORDER BY id DESC LIMIT 1", (sale_id,))
|
||||
cfdi_row = cur.fetchone()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key IN "
|
||||
"('cfdi_facturapi_key', 'cfdi_facturapi_org_id', 'facturapi_secret_key')")
|
||||
tcfg = {k: v for k, v in cur.fetchall()}
|
||||
cur.close()
|
||||
if cfdi_row:
|
||||
cancel_cfdi(conn, cfdi_row[0], '03', tenant_config=tcfg)
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback() # CFDI cancel is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
@@ -748,6 +885,8 @@ def create_quotation():
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -844,18 +983,18 @@ def list_quotations():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("q.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(status)
|
||||
@@ -1138,6 +1277,8 @@ def update_quotation(quot_id):
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1454,7 +1595,11 @@ def get_quotation_pdf(quot_id):
|
||||
# Get business info from tenant config
|
||||
business_info = None
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address','phone','email')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')
|
||||
UNION ALL SELECT 'phone', value FROM tenant_config WHERE key IN ('tenant_telefono','receipt_phone')
|
||||
UNION ALL SELECT 'email', value FROM tenant_config WHERE key = 'tenant_email' LIMIT 5""")
|
||||
config_rows = cur.fetchall()
|
||||
if config_rows:
|
||||
business_info = {r[0]: r[1] for r in config_rows}
|
||||
@@ -1626,6 +1771,8 @@ def convert_quotation(quot_id):
|
||||
try:
|
||||
_validate_stock_availability(conn, items, g.branch_id)
|
||||
_validate_zero_price(conn, items)
|
||||
_validate_price_floor(conn, items)
|
||||
_validate_max_discount(conn, items)
|
||||
sale = process_sale(conn, sale_data)
|
||||
|
||||
# Mark quotation as converted
|
||||
@@ -1786,6 +1933,8 @@ def create_layaway():
|
||||
enriched = _enrich_items(cur, items, customer_id)
|
||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
||||
_validate_zero_price(conn, enriched)
|
||||
_validate_price_floor(conn, enriched)
|
||||
_validate_max_discount(conn, enriched)
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
@@ -1867,6 +2016,23 @@ def create_layaway():
|
||||
'customer_id': customer_id})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': initial_payment,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} deposit failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -1892,18 +2058,18 @@ def list_layaways():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
customer_id = request.args.get('customer_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
status = request.args.get('status')
|
||||
|
||||
if customer_id:
|
||||
where_clauses.append("l.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
if status:
|
||||
where_clauses.append("l.status = %s")
|
||||
params.append(status)
|
||||
@@ -2070,6 +2236,23 @@ def layaway_payment(layaway_id):
|
||||
""", (register_id, amount, f'Apartado #{layaway_id} - abono', g.employee_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): anticipo de cliente
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id,
|
||||
'amount': amount,
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_remaining = round(total - new_paid, 2)
|
||||
@@ -2117,10 +2300,21 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': f'Layaway is {status}'}), 400
|
||||
|
||||
remaining = round(total - paid, 2)
|
||||
|
||||
# Payment method for the conversion sale / final payment. Defaults to cash
|
||||
# but honours whatever the register sent (tarjeta, transferencia, ...).
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
register_id = data.get('register_id')
|
||||
|
||||
try:
|
||||
# If there's a remaining balance, accept a final payment with the complete call
|
||||
if remaining > 0:
|
||||
final_method = data.get('payment_method', 'efectivo')
|
||||
final_method = payment_method
|
||||
cur.execute("""
|
||||
INSERT INTO layaway_payments
|
||||
(layaway_id, amount, payment_method, reference, employee_id)
|
||||
@@ -2131,13 +2325,24 @@ def complete_layaway(layaway_id):
|
||||
paid = total
|
||||
|
||||
# Record cash movement for final payment
|
||||
register_id = data.get('register_id')
|
||||
if register_id and final_method == 'efectivo':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'in', %s, %s, %s)
|
||||
""", (register_id, remaining, f'Apartado #{layaway_id} - pago final', g.employee_id))
|
||||
|
||||
# Accounting entry for the final payment (part of the same transaction)
|
||||
try:
|
||||
from services.accounting_engine import record_layaway_entry
|
||||
record_layaway_entry(conn, {
|
||||
'id': layaway_id, 'amount': remaining, 'payment_method': final_method,
|
||||
})
|
||||
except Exception as acc_err:
|
||||
import logging
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} final payment failed: {acc_err}'
|
||||
)
|
||||
|
||||
# Get layaway items
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
|
||||
@@ -2165,12 +2370,14 @@ def complete_layaway(layaway_id):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, notes)
|
||||
VALUES (%s,%s,%s,%s,'cash','efectivo',%s,%s,%s,%s,%s,0,'PUE','01','completed',%s)
|
||||
VALUES (%s,%s,%s,%s,'cash',%s,%s,%s,%s,%s,%s,0,'PUE',%s,'completed',%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, cust_id, g.employee_id, data.get('register_id'),
|
||||
branch_id, cust_id, g.employee_id, register_id,
|
||||
payment_method,
|
||||
totals_calc['subtotal'], totals_calc['discount_total'],
|
||||
totals_calc['tax_total'], totals_calc['total'], total,
|
||||
forma_pago_sat,
|
||||
f'Completado de apartado #{layaway_id}',
|
||||
))
|
||||
sale_id, sale_created = cur.fetchone()
|
||||
@@ -2201,13 +2408,12 @@ def complete_layaway(layaway_id):
|
||||
))
|
||||
|
||||
# Record payment on register
|
||||
register_id = data.get('register_id')
|
||||
if register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,'efectivo',%s,%s)
|
||||
""", (sale_id, register_id, total, f'Apartado #{layaway_id} completado'))
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, total, f'Apartado #{layaway_id} completado'))
|
||||
|
||||
sale = {
|
||||
'id': sale_id, 'status': 'completed', 'total': totals_calc['total'],
|
||||
@@ -2225,6 +2431,24 @@ def complete_layaway(layaway_id):
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): apply advances and recognize the sale
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_layaway_conversion_entry
|
||||
record_layaway_conversion_entry(conn, {
|
||||
'id': sale['id'],
|
||||
'layaway_id': layaway_id,
|
||||
'subtotal': totals_calc['subtotal'],
|
||||
'tax_total': totals_calc['tax_total'],
|
||||
'total': totals_calc['total'],
|
||||
})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for layaway #{layaway_id} conversion failed: {acc_err}'
|
||||
)
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
@@ -2245,6 +2469,74 @@ def complete_layaway(layaway_id):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
def _record_layaway_cancel_entry(conn, layaway_id):
|
||||
"""Reverse the customer advances (240) booked while collecting a layaway.
|
||||
|
||||
Deposits were journalised as 'Debit Caja/Bancos / Credit 240 Anticipos'
|
||||
when collected; cancelling the layaway refunds them, so the entry is
|
||||
reversed per payment method. Without this, account 240 keeps a phantom
|
||||
liability after cancellation. Mirrors accounting_engine.record_layaway_entry.
|
||||
"""
|
||||
from datetime import date as _date
|
||||
from services.accounting_engine import (
|
||||
_create_entry, _get_account_ids, _ensure_account,
|
||||
_check_period_open, get_next_entry_number,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT payment_method, COALESCE(SUM(amount), 0)
|
||||
FROM layaway_payments WHERE layaway_id = %s
|
||||
GROUP BY payment_method
|
||||
""", (layaway_id,))
|
||||
payments = cur.fetchall()
|
||||
|
||||
credits = []
|
||||
total = 0.0
|
||||
for method, amount in payments:
|
||||
amt = round(float(amount or 0), 2)
|
||||
if amt <= 0:
|
||||
continue
|
||||
credits.append((method, amt))
|
||||
total = round(total + amt, 2)
|
||||
|
||||
if total <= 0:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111'])
|
||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
||||
|
||||
lines = [{
|
||||
'account_id': advance_account,
|
||||
'debit': total,
|
||||
'credit': 0,
|
||||
'description': f'Anticipos de clientes - Cancelacion apartado #{layaway_id}',
|
||||
}]
|
||||
for method, amount in credits:
|
||||
if method in ('transferencia', 'tarjeta'):
|
||||
account_id = accounts['111']
|
||||
desc = f'Bancos - Cancelacion apartado #{layaway_id}'
|
||||
else:
|
||||
account_id = accounts['110']
|
||||
desc = f'Caja - Cancelacion apartado #{layaway_id}'
|
||||
lines.append({
|
||||
'account_id': account_id, 'debit': 0, 'credit': amount,
|
||||
'description': desc,
|
||||
})
|
||||
|
||||
entry_date = _date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
entry_number = get_next_entry_number(conn)
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'egreso',
|
||||
f'Apartado #{layaway_id} - cancelacion (reversa anticipos)',
|
||||
'layaway', layaway_id, lines
|
||||
)
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
@pos_bp.route('/layaways/<int:layaway_id>/cancel', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def cancel_layaway(layaway_id):
|
||||
@@ -2298,6 +2590,19 @@ def cancel_layaway(layaway_id):
|
||||
'items_unreserved': len(layaway_items)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): reverse the customer advances collected
|
||||
# for this layaway, otherwise account 240 keeps a phantom liability.
|
||||
try:
|
||||
import logging
|
||||
_record_layaway_cancel_entry(conn, layaway_id)
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting reversal for cancelled layaway #{layaway_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2311,14 +2616,19 @@ def cancel_layaway(layaway_id):
|
||||
# ─── Returns / Warranty ───────────────────────────
|
||||
|
||||
@pos_bp.route('/returns', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth('pos.sell', 'pos.cancel')
|
||||
def create_return():
|
||||
"""Process a product return with warranty support.
|
||||
|
||||
Requires supervisor-level authorisation (pos.cancel) in addition to
|
||||
pos.sell, since a return moves stock back and refunds money.
|
||||
|
||||
Body: {
|
||||
sale_id: int,
|
||||
items: [{sale_item_id: int, quantity: int, reason: str}],
|
||||
notes: str
|
||||
notes: str,
|
||||
register_id: int (optional; cash register refunding the money),
|
||||
refund_method: str (optional; defaults to the sale's payment method)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
@@ -2337,7 +2647,7 @@ def create_return():
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type, payment_method
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
@@ -2349,6 +2659,7 @@ def create_return():
|
||||
sale_customer_id = sale[1]
|
||||
sale_branch_id = sale[4] or g.branch_id
|
||||
sale_type = sale[5]
|
||||
refund_method = data.get('refund_method') or sale[6] or 'efectivo'
|
||||
|
||||
# Validate each return item against original sale items
|
||||
total_refund = 0
|
||||
@@ -2433,6 +2744,27 @@ def create_return():
|
||||
notes=f'Devolucion #{return_id} de venta #{sale_id}: {vi["reason"]}'
|
||||
)
|
||||
|
||||
# Refund through the cash register: a cash refund leaves the drawer, so
|
||||
# it is recorded as an 'out' movement (see _compute_register_summary,
|
||||
# which subtracts these refunds from the expected cash). Credit sales
|
||||
# instead lower the customer's balance (handled below), and non-cash
|
||||
# refunds (tarjeta/transferencia) never touch the drawer.
|
||||
register_id = data.get('register_id')
|
||||
if not register_id:
|
||||
cur.execute("""
|
||||
SELECT id FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
register_id = reg[0] if reg else None
|
||||
|
||||
if register_id and refund_method == 'efectivo' and sale_type != 'credit':
|
||||
cur.execute("""
|
||||
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||
VALUES (%s, 'out', %s, %s, %s)
|
||||
""", (register_id, total_refund,
|
||||
f'Devolucion #{return_id} de venta #{sale_id}', g.employee_id))
|
||||
|
||||
# Update sale status if all items returned
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(ri2.quantity), 0), COALESCE(SUM(si2.quantity), 0)
|
||||
@@ -2466,6 +2798,19 @@ def create_return():
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Accounting entry (non-blocking): devolucion sobre ventas + IVA + inventario
|
||||
try:
|
||||
import logging
|
||||
from services.accounting_engine import record_return_entry
|
||||
record_return_entry(conn, {'id': return_id, 'sale_id': sale_id})
|
||||
conn.commit()
|
||||
except Exception as acc_err:
|
||||
conn.rollback()
|
||||
logging.getLogger('pos').error(
|
||||
f'Accounting entry for return #{return_id} failed: {acc_err}'
|
||||
)
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
@@ -2494,20 +2839,20 @@ def list_returns():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
sale_id = request.args.get('sale_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
sale_id = _int_arg('sale_id')
|
||||
customer_id = _int_arg('customer_id')
|
||||
if sale_id:
|
||||
where_clauses.append("r.sale_id = %s")
|
||||
params.append(int(sale_id))
|
||||
params.append(sale_id)
|
||||
if customer_id:
|
||||
where_clauses.append("r.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
params.append(customer_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
@@ -2655,7 +3000,9 @@ def print_ticket(sale_id):
|
||||
# Fetch business info from config
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2736,7 +3083,9 @@ def print_remission(sale_id):
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
cur.execute("""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')""")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
@@ -2765,6 +3114,67 @@ def print_remission(sale_id):
|
||||
})
|
||||
|
||||
|
||||
# ─── Cross-sell recommendations (A10) ───────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/recommendations', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def recommendations():
|
||||
"""Suggest complementary parts for the items currently in the cart.
|
||||
|
||||
Body: {"inventory_ids": [1,2,3], "limit": 5}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_ids = data.get('inventory_ids') or data.get('items') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return jsonify({'error': 'inventory_ids must be a list'}), 400
|
||||
try:
|
||||
inventory_ids = [int(x) for x in raw_ids][:50]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'inventory_ids must be a list of integers'}), 400
|
||||
|
||||
try:
|
||||
limit = int(data.get('limit') or 5)
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({'recommendations': []})
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
recs = get_cross_sell(conn, inventory_ids, limit=limit)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({'recommendations': recs})
|
||||
|
||||
|
||||
# ─── Business anomaly report (A5) ───────────────────────────────────────────
|
||||
|
||||
@pos_bp.route('/reports/anomalies', methods=['GET'])
|
||||
@require_auth('reports.view')
|
||||
def report_anomalies():
|
||||
"""Robust anomaly scan over discounts, below-cost sales, cancellations
|
||||
and off-hours tickets.
|
||||
|
||||
Query params: days (90), min_samples (5), z (3.5), limit (50).
|
||||
"""
|
||||
days = _int_arg('days', 90, 1, 3650)
|
||||
min_samples = _int_arg('min_samples', _ANOM_MIN_SAMPLES, 1, 1000)
|
||||
z_threshold = _float_arg('z', _ANOM_Z, 0.5, 20.0)
|
||||
limit = _int_arg('limit', 50, 1, 200)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = detect_anomalies(
|
||||
conn, days=days, min_samples=min_samples,
|
||||
z_threshold=z_threshold, limit=limit,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
@@ -123,7 +123,10 @@ def public_catalog_search():
|
||||
from services.catalog_service import smart_search
|
||||
master = get_master_conn()
|
||||
try:
|
||||
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
||||
# Catálogo público: scope 'catalog' → flag SEARCH_RERANK_ENABLED
|
||||
# (el POS, sensible a latencia, usa el scope 'pos' apagado por default).
|
||||
results = smart_search(master, q, conn, branch_id=None, limit=limit,
|
||||
rerank_scope='catalog')
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@@ -777,7 +777,9 @@ def print_service_order_ticket(so_id):
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
|
||||
"""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')"""
|
||||
)
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
|
||||
240
pos/blueprints/storefront_bp.py
Normal file
240
pos/blueprints/storefront_bp.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# /home/Autopartes/pos/blueprints/storefront_bp.py
|
||||
"""Storefront público por tenant: vitrina web con carrito → pedido al POS.
|
||||
|
||||
Endpoints públicos (sin auth) protegidos por rate-limit por IP:
|
||||
GET /pos/api/store/info — nombre del negocio
|
||||
GET /pos/api/store/search — búsqueda de inventario con stock y precio público
|
||||
POST /pos/api/store/order — crea pedido (venta pendiente de pago) con reserva de stock
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from middleware_tenant import resolve_tenant
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services.pos_engine import process_sale
|
||||
|
||||
storefront_bp = Blueprint('storefront', __name__, url_prefix='/pos/api')
|
||||
|
||||
_RATE = defaultdict(deque)
|
||||
RATE_LIMIT = 60 # requests por minuto por IP
|
||||
RATE_WINDOW = 60
|
||||
|
||||
|
||||
def _rate_ok():
|
||||
ip = (request.headers.get('X-Real-IP') or request.remote_addr or 'x')
|
||||
now = time.time()
|
||||
dq = _RATE[ip]
|
||||
while dq and dq[0] < now - RATE_WINDOW:
|
||||
dq.popleft()
|
||||
if len(dq) >= RATE_LIMIT:
|
||||
return False
|
||||
dq.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _require_tenant():
|
||||
if not getattr(g, 'tenant_id', None):
|
||||
return None
|
||||
return g.tenant_id
|
||||
|
||||
|
||||
def _business_name(tenant_id):
|
||||
"""Nombre del negocio desde la DB maestra (cache 5 min por proceso)."""
|
||||
cache = getattr(_business_name, '_cache', None)
|
||||
if cache is None:
|
||||
cache = _business_name._cache = {}
|
||||
now = time.time()
|
||||
hit = cache.get(tenant_id)
|
||||
if hit and hit[1] > now:
|
||||
return hit[0]
|
||||
try:
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
name = row[0] if row else 'Refaccionaria'
|
||||
except Exception:
|
||||
name = 'Refaccionaria'
|
||||
cache[tenant_id] = (name, now + 300)
|
||||
return name
|
||||
|
||||
|
||||
@storefront_bp.route('/store/info', methods=['GET'])
|
||||
def store_info():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
return jsonify({'name': _business_name(tid)})
|
||||
|
||||
|
||||
@storefront_bp.route('/store/search', methods=['GET'])
|
||||
def store_search():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
if not _rate_ok():
|
||||
return jsonify({'error': 'Demasiadas búsquedas. Intenta en un minuto.'}), 429
|
||||
|
||||
q = (request.args.get('q') or '').strip()
|
||||
def _int_arg(name, default, lo, hi):
|
||||
try:
|
||||
return max(lo, min(hi, int(request.args.get(name, default) or default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
page = _int_arg('page', 1, 1, 10000)
|
||||
per_page = _int_arg('per_page', 20, 1, 30)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = get_tenant_conn(tid)
|
||||
cur = conn.cursor()
|
||||
params = []
|
||||
where = "i.is_active = true"
|
||||
if q:
|
||||
where += """ AND (i.name ILIKE %s OR i.part_number ILIKE %s
|
||||
OR i.id IN (SELECT a.inventory_id FROM inventory_sku_aliases a
|
||||
WHERE a.sku ILIKE %s))"""
|
||||
like = f"%{q}%"
|
||||
params += [like, like, like]
|
||||
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.image_url, i.description,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE {where}
|
||||
ORDER BY (COALESCE(s.stock, 0) > 0) DESC, i.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
items = [{
|
||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
|
||||
'price': float(r[4] or 0), 'image_url': r[5] or '',
|
||||
'description': (r[6] or '')[:200], 'stock': int(r[7] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'items': items, 'page': page, 'total': total,
|
||||
'total_pages': max(1, (total + per_page - 1) // per_page)})
|
||||
|
||||
|
||||
@storefront_bp.route('/store/order', methods=['POST'])
|
||||
def store_order():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
if not _rate_ok():
|
||||
return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('customer_name') or '').strip()
|
||||
phone = ''.join(ch for ch in (data.get('customer_phone') or '') if ch.isdigit())
|
||||
items = data.get('items') or []
|
||||
notes = (data.get('notes') or '').strip()
|
||||
delivery = bool(data.get('delivery'))
|
||||
|
||||
if not name or not phone or len(phone) < 10:
|
||||
return jsonify({'error': 'Nombre y teléfono (10 dígitos) son requeridos'}), 400
|
||||
if not items:
|
||||
return jsonify({'error': 'El carrito está vacío'}), 400
|
||||
|
||||
conn = get_tenant_conn(tid)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Sucursal principal
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Tienda no disponible'}), 503
|
||||
branch_id = row[0]
|
||||
|
||||
# Cliente: buscar por teléfono, si no existe crearlo
|
||||
cur.execute("SELECT id FROM customers WHERE phone = %s ORDER BY id LIMIT 1", (phone,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
customer_id = row[0]
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, phone, price_tier, is_active)
|
||||
VALUES (%s, %s, 1, true) RETURNING id
|
||||
""", (name, phone))
|
||||
customer_id = cur.fetchone()[0]
|
||||
|
||||
# Validar stock y precios (precio SIEMPRE del inventario, no del cliente)
|
||||
resolved = []
|
||||
for it in items:
|
||||
inv_id = it.get('inventory_id')
|
||||
qty = int(it.get('quantity', 0) or 0)
|
||||
if not inv_id or qty <= 0:
|
||||
continue
|
||||
cur.execute("""
|
||||
SELECT i.id, i.name, i.part_number, i.price_1, i.tax_rate,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.id = %s AND i.is_active
|
||||
""", (inv_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Producto no disponible (#{inv_id})'}), 400
|
||||
if (r[5] or 0) < qty:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Sin stock suficiente: {r[2]}. Disponible: {int(r[5] or 0)}'}), 400
|
||||
resolved.append({
|
||||
'inventory_id': r[0], 'quantity': qty,
|
||||
'unit_price': float(r[3] or 0), 'tax_rate': float(r[4] or 0),
|
||||
'discount_pct': 0,
|
||||
})
|
||||
if not resolved:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'El carrito está vacío'}), 400
|
||||
|
||||
delivery_note = ' | Envío a domicilio solicitado' if delivery else ''
|
||||
sale_data = {
|
||||
'items': resolved,
|
||||
'customer_id': customer_id,
|
||||
'payment_method': 'pendiente',
|
||||
'sale_type': 'cash',
|
||||
'branch_id': branch_id,
|
||||
'amount_paid': 0,
|
||||
'notes': f'Pedido web de {name}{delivery_note}' + (f' | {notes}' if notes else ''),
|
||||
}
|
||||
try:
|
||||
sale = process_sale(conn, sale_data)
|
||||
conn.commit()
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No se pudo registrar el pedido'}), 500
|
||||
cur.close()
|
||||
|
||||
# Notificar al dueño (best effort)
|
||||
try:
|
||||
from services.notification_engine import notify_new_sale
|
||||
notify_new_sale(conn, tid, sale['id'], sale['total'], 'web')
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'order_id': sale['id'],
|
||||
'total': sale['total'],
|
||||
'message': 'Pedido registrado. Te contactaremos para confirmar pago y entrega.',
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
Endpoints (all under /pos/api):
|
||||
GET/POST /warranties
|
||||
GET /warranties/<id>
|
||||
GET /warranties/<id>/print (HTML)
|
||||
GET /customers/<id>/warranties
|
||||
POST /warranty-claims
|
||||
GET /warranty-claims
|
||||
@@ -11,7 +12,7 @@ Endpoints (all under /pos/api):
|
||||
PUT /warranty-claims/<id>/close
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.warranty_engine import (
|
||||
@@ -43,7 +44,10 @@ def get_warranties():
|
||||
def post_warranty():
|
||||
"""Register a warranty (usually called at sale time)."""
|
||||
data = request.get_json() or {}
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id']
|
||||
# warranty_months is required unless a day-based coverage is given (used parts)
|
||||
if 'warranty_months' not in data and 'warranty_days' not in data:
|
||||
required.append('warranty_months')
|
||||
missing = [f for f in required if f not in data]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
||||
@@ -56,11 +60,12 @@ def post_warranty():
|
||||
sale_item_id=data['sale_item_id'],
|
||||
inventory_id=data['inventory_id'],
|
||||
customer_id=data['customer_id'],
|
||||
warranty_months=int(data['warranty_months']),
|
||||
warranty_months=int(data.get('warranty_months') or 0),
|
||||
supplier_id=data.get('supplier_id'),
|
||||
part_number=data.get('part_number'),
|
||||
name=data.get('name'),
|
||||
notes=data.get('notes'),
|
||||
warranty_days=int(data['warranty_days']) if data.get('warranty_days') else None,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -87,6 +92,49 @@ def get_warranty_detail(warranty_id):
|
||||
return jsonify(w)
|
||||
|
||||
|
||||
@warranty_bp.route('/warranties/<int:warranty_id>/print', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def print_warranty(warranty_id):
|
||||
"""Printable warranty receipt (HTML).
|
||||
|
||||
Serves both month-based and Yonke day-based coverage so a used-part buyer
|
||||
gets a physical voucher with the coverage window.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
w = get_warranty(conn, warranty_id)
|
||||
if not w:
|
||||
return jsonify({'error': 'Warranty not found'}), 404
|
||||
business = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': '', 'phone': ''}
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT 'name' AS k, value FROM tenant_config
|
||||
WHERE key IN ('tenant_nombre', 'receipt_store_name')
|
||||
UNION ALL SELECT 'rfc', value FROM tenant_config
|
||||
WHERE key IN ('tenant_rfc', 'receipt_rfc')
|
||||
UNION ALL SELECT 'address', value FROM tenant_config
|
||||
WHERE key IN ('tenant_direccion', 'receipt_address')
|
||||
UNION ALL SELECT 'phone', value FROM tenant_config
|
||||
WHERE key IN ('tenant_telefono', 'receipt_phone')
|
||||
""")
|
||||
for k, v in cur.fetchall():
|
||||
if v:
|
||||
business[k] = v
|
||||
cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
coverage = (f"{w['warranty_days']} días" if w.get('warranty_days')
|
||||
else f"{w.get('warranty_months') or 0} meses")
|
||||
html = render_template_string(
|
||||
WARRANTY_PRINT_TEMPLATE, w=w, business=business, coverage=coverage
|
||||
)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_customer_warranties(customer_id):
|
||||
@@ -206,3 +254,68 @@ def put_close_claim(claim_id):
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── Printable warranty receipt ─────────────────────────────────────────────
|
||||
|
||||
WARRANTY_PRINT_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Garantia #{{ w.id }}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}
|
||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:24px;text-align:center}
|
||||
.header h1{font-size:20px;font-weight:700;margin-bottom:4px}
|
||||
.header p{font-size:13px;opacity:.85}
|
||||
.body{padding:24px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
||||
.meta strong{color:#111;display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
||||
.badge-active{background:#d1fae5;color:#065f46}
|
||||
.badge-expired,.badge-claimed,.badge-void{background:#fee2e2;color:#991b1b}
|
||||
.terms{font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px}
|
||||
.footer{text-align:center;padding:14px;font-size:12px;color:#9ca3af}
|
||||
.actions{padding:0 24px 20px;text-align:center}
|
||||
.btn{display:inline-block;padding:12px 22px;border-radius:10px;border:none;background:#1f2937;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
|
||||
@media print{body{background:#fff;padding:0}.card{box-shadow:none}.actions{display:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Comprobante de Garantia</h1>
|
||||
<p>{{ business.name }}{% if business.rfc %} · RFC {{ business.rfc }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div><strong>Folio</strong>G-{{ w.id }}</div>
|
||||
<div><strong>Estado</strong><span class="badge badge-{{ w.status }}">{{ w.status }}</span></div>
|
||||
<div><strong>Cliente</strong>{{ w.customer_name or 'Publico general' }}</div>
|
||||
<div><strong>Venta</strong>#{{ w.sale_id or '—' }}</div>
|
||||
<div><strong>Pieza</strong>{{ w.name or '—' }}</div>
|
||||
<div><strong>No. parte</strong>{{ w.part_number or '—' }}</div>
|
||||
<div><strong>Inicio</strong>{{ w.start_date }}</div>
|
||||
<div><strong>Vence</strong>{{ w.end_date }}</div>
|
||||
<div><strong>Cobertura</strong>{{ coverage }}</div>
|
||||
</div>
|
||||
<div class="terms">
|
||||
<p>Esta garantia ampara defectos de funcionamiento de la pieza por {{ coverage }} a partir de
|
||||
la fecha de compra. Presente este comprobante junto con la pieza para cualquier reclamacion.</p>
|
||||
{% if w.notes %}<p>Notas: {{ w.notes }}</p>{% endif %}
|
||||
{% if business.address %}<p>{{ business.address }}</p>{% endif %}
|
||||
{% if business.phone %}<p>Tel: {{ business.phone }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="window.print()">Imprimir</button>
|
||||
</div>
|
||||
<div class="footer">{{ business.name }} — Comprobante generado el {{ w.created_at[:10] }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -34,6 +34,7 @@ def _get_whatsapp_config(conn):
|
||||
|
||||
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
||||
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
||||
provider = (config.get('whatsapp_provider') or 'baileys').lower()
|
||||
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
||||
if enabled_raw == 'true':
|
||||
enabled = True
|
||||
@@ -43,7 +44,17 @@ def _get_whatsapp_config(conn):
|
||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||
enabled = bool(bridge_url)
|
||||
|
||||
return {
|
||||
return { 'provider': provider,
|
||||
'phone_number_id': config.get('wa_phone_number_id', ''),
|
||||
'access_token': config.get('wa_access_token', ''),
|
||||
'app_secret': config.get('wa_app_secret', ''),
|
||||
'verify_token': config.get('wa_verify_token', ''),
|
||||
'graph_url': config.get('wa_graph_url', ''),
|
||||
'ai_persona': config.get('wa_ai_persona', ''),
|
||||
'api_base': config.get('wa_api_base', ''),
|
||||
'api_key': config.get('wa_api_key', ''),
|
||||
'public_base': config.get('wa_public_base', ''),
|
||||
|
||||
'bridge_url': bridge_url,
|
||||
'bridge_key': bridge_key,
|
||||
'enabled': enabled,
|
||||
@@ -359,6 +370,11 @@ def webhook():
|
||||
return jsonify({'ok': True})
|
||||
|
||||
msg = whatsapp_service.process_incoming(data)
|
||||
return _process_incoming_whatsapp_msg(msg)
|
||||
|
||||
|
||||
def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
"""Procesa un mensaje entrante normalizado (Baileys o Meta Cloud API)."""
|
||||
if not msg.get('phone') or msg.get('from_me'):
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@@ -393,7 +409,7 @@ def webhook():
|
||||
|
||||
|
||||
# Resolve tenant
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
@@ -494,7 +510,7 @@ def webhook():
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, followup)
|
||||
cur_fu = tenant_conn.cursor()
|
||||
cur_fu.execute(
|
||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||
@@ -544,7 +560,7 @@ def webhook():
|
||||
|
||||
# 7. Send reply
|
||||
if reply:
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
|
||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||
|
||||
# Log outgoing
|
||||
@@ -568,7 +584,7 @@ def webhook():
|
||||
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||
f"Por favor llámanos directamente al {phone_branch}."
|
||||
)
|
||||
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -583,10 +599,221 @@ def webhook():
|
||||
master_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WA CRM / Meta Cloud API webhook (1 número por refaccionaria)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import os as _os_wa
|
||||
|
||||
|
||||
def _resolve_tenant_by_pnid(phone_number_id):
|
||||
"""Resolve (tenant_id, wa_config) by Meta phone_number_id. Cached per process."""
|
||||
if not phone_number_id:
|
||||
return None, None
|
||||
cache = getattr(_resolve_tenant_by_pnid, '_cache', None)
|
||||
if cache is None:
|
||||
cache = _resolve_tenant_by_pnid._cache = {}
|
||||
if phone_number_id in cache:
|
||||
return cache[phone_number_id]
|
||||
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
|
||||
tenants = mcur.fetchall()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
from tenant_db import get_tenant_conn_by_dbname
|
||||
for tid, db_name in tenants:
|
||||
try:
|
||||
tconn = get_tenant_conn_by_dbname(db_name)
|
||||
cfg = _get_whatsapp_config(tconn)
|
||||
tconn.close()
|
||||
if str(cfg.get('phone_number_id') or '') == str(phone_number_id):
|
||||
cache[phone_number_id] = (tid, cfg)
|
||||
return tid, cfg
|
||||
except Exception:
|
||||
continue
|
||||
cache[phone_number_id] = (None, None)
|
||||
return None, None
|
||||
|
||||
|
||||
def _meta_download_media_b64(cfg, media_id):
|
||||
"""Download Meta media (audio/image/document) → base64 + mimetype."""
|
||||
import requests as _rq
|
||||
import base64 as _b64
|
||||
try:
|
||||
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
|
||||
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
|
||||
info = _rq.get(f"{base}/{media_id}", headers=headers, timeout=20).json()
|
||||
url = info.get('url')
|
||||
if not url:
|
||||
return None, None
|
||||
data = _rq.get(url, headers=headers, timeout=60).content
|
||||
return _b64.b64encode(data).decode(), info.get('mime_type', 'application/octet-stream')
|
||||
except Exception as e:
|
||||
print(f"[WA-CRM] media download failed: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _meta_parse_msg(payload, cfg):
|
||||
"""Parse a Meta Cloud API webhook payload into the internal msg dict.
|
||||
Returns (phone_number_id, msg) — msg may be None for non-message events."""
|
||||
pnid = None
|
||||
for entry in payload.get('entry', []):
|
||||
for change in entry.get('changes', []):
|
||||
value = change.get('value', {})
|
||||
pnid = value.get('metadata', {}).get('phone_number_id', pnid)
|
||||
if not value.get('messages'):
|
||||
continue
|
||||
m = value['messages'][0]
|
||||
contact = (value.get('contacts') or [{}])[0]
|
||||
push_name = (contact.get('profile') or {}).get('name', '')
|
||||
msg = {
|
||||
'phone': m.get('from', ''),
|
||||
'from_me': False,
|
||||
'message_id': m.get('id', ''),
|
||||
'push_name': push_name,
|
||||
'text': '',
|
||||
'media_kind': 'text',
|
||||
}
|
||||
mtype = m.get('type', '')
|
||||
if mtype == 'text':
|
||||
msg['text'] = m.get('text', {}).get('body', '')
|
||||
elif mtype == 'image':
|
||||
msg['media_kind'] = 'image'
|
||||
msg['text'] = m.get('image', {}).get('caption', '') or '(imagen)'
|
||||
b64, mt = _meta_download_media_b64(cfg, m.get('image', {}).get('id'))
|
||||
if b64:
|
||||
msg['media_base64'] = b64
|
||||
msg['media_mimetype'] = mt
|
||||
elif mtype == 'audio' or mtype == 'voice':
|
||||
msg['media_kind'] = 'audio'
|
||||
b64, mt = _meta_download_media_b64(cfg, m.get('audio', {}).get('id'))
|
||||
if b64:
|
||||
msg['media_base64'] = b64
|
||||
msg['media_mimetype'] = mt or 'audio/ogg'
|
||||
elif mtype == 'document':
|
||||
msg['media_kind'] = 'image'
|
||||
msg['text'] = m.get('document', {}).get('caption', '') or '(documento)'
|
||||
b64, mt = _meta_download_media_b64(cfg, m.get('document', {}).get('id'))
|
||||
if b64:
|
||||
msg['media_base64'] = b64
|
||||
msg['media_mimetype'] = mt
|
||||
elif mtype == 'location':
|
||||
lat = m.get('location', {}).get('latitude')
|
||||
lng = m.get('location', {}).get('longitude')
|
||||
msg['media_kind'] = 'location'
|
||||
msg['latitude'] = lat
|
||||
msg['longitude'] = lng
|
||||
return pnid, msg
|
||||
return pnid, None
|
||||
|
||||
|
||||
@whatsapp_bp.route('/wacrm/webhook', methods=['GET'])
|
||||
def wacrm_webhook_verify():
|
||||
"""Meta webhook verification handshake."""
|
||||
verify_token = request.args.get('hub.verify_token', '')
|
||||
challenge = request.args.get('hub.challenge', '')
|
||||
if not challenge:
|
||||
return 'Bad request', 400
|
||||
|
||||
expected_global = _os_wa.environ.get('WA_VERIFY_TOKEN', '')
|
||||
if expected_global and verify_token == expected_global:
|
||||
return challenge, 200
|
||||
|
||||
# per-tenant verify tokens
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
|
||||
tenants = mcur.fetchall()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
from tenant_db import get_tenant_conn_by_dbname
|
||||
for tid, db_name in tenants:
|
||||
try:
|
||||
tconn = get_tenant_conn_by_dbname(db_name)
|
||||
cur = tconn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'wa_verify_token'")
|
||||
row = cur.fetchone()
|
||||
tconn.close()
|
||||
if row and row[0] and verify_token == row[0]:
|
||||
return challenge, 200
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return 'Forbidden', 403
|
||||
|
||||
|
||||
@whatsapp_bp.route('/wacrm/webhook', methods=['POST'])
|
||||
def wacrm_webhook():
|
||||
"""Meta Cloud API events (WA CRM self-hosted, 1 número por tenant)."""
|
||||
raw = request.get_data()
|
||||
payload = request.get_json(silent=True) or {}
|
||||
signature = request.headers.get('X-Hub-Signature-256', '')
|
||||
|
||||
# First parse to get phone_number_id (to resolve tenant + its app secret)
|
||||
pnid_probe = None
|
||||
for entry in payload.get('entry', []):
|
||||
for change in entry.get('changes', []):
|
||||
pnid_probe = change.get('value', {}).get('metadata', {}).get('phone_number_id')
|
||||
break
|
||||
if pnid_probe:
|
||||
break
|
||||
|
||||
tenant_id, wa_cfg = _resolve_tenant_by_pnid(pnid_probe) if pnid_probe else (None, None)
|
||||
app_secret = (wa_cfg or {}).get('app_secret') or _os_wa.environ.get('WA_APP_SECRET', '')
|
||||
|
||||
from services.whatsapp_service import meta_verify_signature
|
||||
if not meta_verify_signature(app_secret, raw, signature):
|
||||
return 'Invalid signature', 403
|
||||
|
||||
pnid, msg = _meta_parse_msg(payload, wa_cfg or {})
|
||||
|
||||
# Formato de automatización de WA CRM: {"phone": "...", "message": "..."} (o from/text)
|
||||
if not msg:
|
||||
raw_phone = payload.get('phone') or payload.get('from') or payload.get('wa_id')
|
||||
raw_text = payload.get('message') or payload.get('text') or payload.get('body')
|
||||
if raw_phone and raw_text is not None:
|
||||
msg = {
|
||||
'phone': str(raw_phone),
|
||||
'from_me': bool(payload.get('from_me')),
|
||||
'message_id': payload.get('message_id') or payload.get('id') or '',
|
||||
'push_name': payload.get('name') or payload.get('push_name') or '',
|
||||
'text': str(raw_text),
|
||||
'media_kind': 'text',
|
||||
}
|
||||
if not msg:
|
||||
return jsonify({'ok': True}) # statuses / reads / non-message events
|
||||
|
||||
if not tenant_id:
|
||||
tid_arg = request.args.get('tenant_id', type=int)
|
||||
if tid_arg:
|
||||
from tenant_db import get_tenant_conn as _gtc
|
||||
try:
|
||||
_c = _gtc(tid_arg)
|
||||
wa_cfg = _get_whatsapp_config(_c)
|
||||
_c.close()
|
||||
tenant_id = tid_arg
|
||||
except Exception:
|
||||
tenant_id = None
|
||||
if not tenant_id:
|
||||
print(f"[WA-CRM] No tenant for phone_number_id={pnid_probe}")
|
||||
return jsonify({'ok': True})
|
||||
|
||||
return _process_incoming_whatsapp_msg(msg, tenant_id_override=tenant_id)
|
||||
|
||||
|
||||
@whatsapp_bp.route('/send', methods=['POST'])
|
||||
@require_auth()
|
||||
def send():
|
||||
|
||||
911
pos/blueprints/yonke_bp.py
Normal file
911
pos/blueprints/yonke_bp.py
Normal file
@@ -0,0 +1,911 @@
|
||||
# /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()
|
||||
Reference in New Issue
Block a user