Compare commits
2 Commits
159d0ed625
...
4866823ba9
| Author | SHA1 | Date | |
|---|---|---|---|
| 4866823ba9 | |||
| a236187f3a |
@@ -28,6 +28,10 @@ Environment=NEXUS_SERVER_HOST=127.0.0.1
|
||||
|
||||
# ─── Security (CHANGE THIS) ────────────────────────────────────────────────
|
||||
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
Environment=INTERNAL_API_KEY=c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2
|
||||
|
||||
# ─── POS Internal API (for WhatsApp bridge orchestration) ──────────────────
|
||||
Environment=POS_INTERNAL_URL=http://192.168.10.91:5001
|
||||
|
||||
# ─── Redis (optional, health check only) ───────────────────────────────────
|
||||
Environment=REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
11
pos/app.py
11
pos/app.py
@@ -59,6 +59,9 @@ def create_app():
|
||||
from blueprints.marketplace_bp import marketplace_bp
|
||||
app.register_blueprint(marketplace_bp)
|
||||
|
||||
from blueprints.marketplace_external_bp import marketplace_ext_bp
|
||||
app.register_blueprint(marketplace_ext_bp)
|
||||
|
||||
from blueprints.peer_bp import peer_bp
|
||||
app.register_blueprint(peer_bp)
|
||||
|
||||
@@ -180,6 +183,14 @@ def create_app():
|
||||
def pos_marketplace():
|
||||
return render_template('marketplace.html')
|
||||
|
||||
@app.route('/pos/marketplace-external')
|
||||
def pos_marketplace_external():
|
||||
return render_template('marketplace_external.html')
|
||||
|
||||
@app.route('/pos/marketplace-external/callback')
|
||||
def pos_marketplace_external_callback():
|
||||
return render_template('marketplace_external.html')
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@@ -741,3 +741,45 @@ def close_period():
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.read')
|
||||
def api_accounting_stats():
|
||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Count asset accounts with positive balance (cuentas por cobrar)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT a.id
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||
WHERE a.type = 'activo' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
|
||||
) x
|
||||
""")
|
||||
cxc = cur.fetchone()[0] or 0
|
||||
|
||||
# Count liability accounts with positive balance (cuentas por pagar)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT a.id
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||
WHERE a.type = 'pasivo' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
|
||||
) x
|
||||
""")
|
||||
cxp = cur.fetchone()[0] or 0
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'cuentas_cobrar': cxc,
|
||||
'cuentas_pagar': cxp,
|
||||
})
|
||||
|
||||
@@ -35,6 +35,25 @@ def _oem_blocked():
|
||||
return None
|
||||
|
||||
|
||||
def _get_allowed_brands(tenant_conn):
|
||||
"""Read allowed part brands from tenant_config. Returns list or None."""
|
||||
import json
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
brands = json.loads(row[0])
|
||||
if isinstance(brands, list) and brands:
|
||||
return brands
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
|
||||
def _with_conns(fn):
|
||||
"""Helper: open master + tenant connections, call fn, close both.
|
||||
fn receives (master_conn, tenant_conn, branch_id).
|
||||
@@ -71,6 +90,32 @@ def _master_only(fn):
|
||||
except: pass
|
||||
|
||||
|
||||
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
|
||||
"""Filter a list of part dicts to only include those with aftermarket equivalents
|
||||
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
|
||||
if not allowed_brands or not parts_data:
|
||||
return parts_data
|
||||
part_ids = []
|
||||
for p in parts_data:
|
||||
pid = p.get('id_part') or p.get('id')
|
||||
if pid is not None:
|
||||
part_ids.append(pid)
|
||||
if not part_ids:
|
||||
return parts_data
|
||||
cur = master_conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ap.oem_part_id
|
||||
FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", (part_ids, allowed_brands))
|
||||
allowed_ids = {r[0] for r in cur.fetchall()}
|
||||
finally:
|
||||
cur.close()
|
||||
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
|
||||
|
||||
|
||||
# ─── Hierarchy navigation (master DB only) ───
|
||||
|
||||
@catalog_bp.route('/brands', methods=['GET'])
|
||||
@@ -150,13 +195,14 @@ def categories():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if mode == 'local':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/groups', methods=['GET'])
|
||||
@@ -317,6 +363,7 @@ def parts():
|
||||
return blocked
|
||||
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if use_nexpart_nav:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||
@@ -330,6 +377,9 @@ def parts():
|
||||
result = catalog_service.get_parts(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
)
|
||||
if allowed_brands:
|
||||
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
|
||||
result['allowed_brands'] = allowed_brands or []
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
|
||||
@@ -358,8 +408,11 @@ def search():
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
|
||||
return jsonify({'data': data})
|
||||
if allowed_brands:
|
||||
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
||||
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@@ -635,10 +688,20 @@ def brand_categories():
|
||||
if not brand:
|
||||
return jsonify({'error': 'brand parameter required'}), 400
|
||||
|
||||
def _query(master):
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
brand_filter = ""
|
||||
params = [brand]
|
||||
if allowed_brands:
|
||||
brand_filter = """AND EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap2
|
||||
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||
)"""
|
||||
params.append(allowed_brands)
|
||||
cur.execute(f"""
|
||||
SELECT pc.id_part_category,
|
||||
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
||||
pc.slug,
|
||||
@@ -648,20 +711,22 @@ def brand_categories():
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{brand_filter}
|
||||
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
||||
ORDER BY part_count DESC
|
||||
""", (brand,))
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'categories': [
|
||||
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
||||
for r in rows
|
||||
]
|
||||
],
|
||||
'allowed_brands': allowed_brands or []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _master_only(_query)
|
||||
return _with_conns(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||
@@ -680,21 +745,110 @@ def brand_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
params = [brand]
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [brand]
|
||||
|
||||
if category_id:
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
# Get parts from the brand catalog
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -715,7 +869,7 @@ def brand_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM part_vehicle_preview pvp
|
||||
@@ -725,10 +879,9 @@ def brand_parts():
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
@@ -759,6 +912,7 @@ def brand_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
@@ -784,7 +938,8 @@ def mye_parts():
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
# Build dynamic filters
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [mye_id]
|
||||
@@ -793,12 +948,103 @@ def mye_parts():
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
# Get aftermarket parts
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Local stock keyed by OEM part id
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'mye_id': mye_id,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
# Get parts
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
@@ -819,7 +1065,7 @@ def mye_parts():
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM vehicle_parts vp
|
||||
@@ -829,10 +1075,9 @@ def mye_parts():
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", params)
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Enrich with local stock if available
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
@@ -863,6 +1108,7 @@ def mye_parts():
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
@@ -91,7 +91,7 @@ def get_customer(customer_id):
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
||||
is_active, vehicle_info, created_at
|
||||
is_active, vehicle_info, created_at, max_discount_pct
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -103,7 +103,7 @@ def get_customer(customer_id):
|
||||
customer = dict(zip(cols, row))
|
||||
|
||||
# Convert Decimal to float
|
||||
for k in ('credit_limit', 'credit_balance'):
|
||||
for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
|
||||
if customer.get(k) is not None:
|
||||
customer[k] = float(customer[k])
|
||||
|
||||
@@ -213,7 +213,7 @@ def update_customer(customer_id):
|
||||
# Build dynamic update
|
||||
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
||||
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
||||
'vehicle_info', 'is_active', 'branch_id']
|
||||
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
|
||||
@@ -26,6 +26,26 @@ from tasks import sync_vehicle_compatibility_task
|
||||
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
|
||||
|
||||
|
||||
def _get_tier_discounts(conn):
|
||||
"""Read global tier discounts from DB. Returns dict {tier_id: discount_pct}."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT tier_id, discount_pct FROM tier_discounts")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
|
||||
def _apply_tier_discounts(price_1, discounts):
|
||||
"""Given a base price and discount dict, return (price_2, price_3)."""
|
||||
if not price_1:
|
||||
return 0, 0
|
||||
disc2 = discounts.get(2, 0)
|
||||
disc3 = discounts.get(3, 0)
|
||||
p2 = round(float(price_1) * (1 - disc2 / 100), 2)
|
||||
p3 = round(float(price_1) * (1 - disc3 / 100), 2)
|
||||
return p2, p3
|
||||
|
||||
|
||||
# ─── AI Classification ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@@ -254,6 +274,16 @@ def create_item():
|
||||
mcur.close(); mconn.close()
|
||||
barcode = generate_barcode(conn, db_name)
|
||||
|
||||
# Auto-calculate tier prices from global discounts
|
||||
discounts = _get_tier_discounts(conn)
|
||||
price_1 = data.get('price_1', 0)
|
||||
price_2, price_3 = _apply_tier_discounts(price_1, discounts)
|
||||
# Allow override if explicitly sent (backward compat)
|
||||
if 'price_2' in data:
|
||||
price_2 = data['price_2']
|
||||
if 'price_3' in data:
|
||||
price_3 = data['price_3']
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO inventory
|
||||
@@ -267,7 +297,7 @@ def create_item():
|
||||
data.get('description'), data.get('category_id'), data.get('brand'),
|
||||
json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None,
|
||||
data.get('unit', 'PZA'), data.get('cost', 0),
|
||||
data.get('price_1', 0), data.get('price_2', 0), data.get('price_3', 0),
|
||||
price_1, price_2, price_3,
|
||||
data.get('tax_rate', 0.16),
|
||||
data.get('min_stock', 0), data.get('max_stock', 0),
|
||||
data.get('location'), data.get('image_url'), data.get('catalog_part_id')
|
||||
@@ -365,6 +395,15 @@ def update_item(item_id):
|
||||
if changing_prices and not has_permission('config.edit_prices'):
|
||||
return jsonify({'error': 'Permission config.edit_prices required to change prices'}), 403
|
||||
|
||||
# Auto-calculate tier prices if price_1 changes and no explicit override
|
||||
discounts = _get_tier_discounts(conn)
|
||||
if 'price_1' in data and ('price_2' not in data or 'price_3' not in data):
|
||||
p2, p3 = _apply_tier_discounts(data['price_1'], discounts)
|
||||
if 'price_2' not in data:
|
||||
data['price_2'] = p2
|
||||
if 'price_3' not in data:
|
||||
data['price_3'] = p3
|
||||
|
||||
# Build dynamic update
|
||||
allowed = ['part_number', 'barcode', 'name', 'description', 'category_id', 'brand',
|
||||
'vehicle_compatibility', 'unit', 'cost', 'price_1', 'price_2', 'price_3',
|
||||
@@ -536,6 +575,33 @@ def delete_image(item_id):
|
||||
return jsonify({'message': 'Image deleted'})
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item(item_id):
|
||||
"""Soft-delete an inventory item (mark is_active = false).
|
||||
|
||||
Keeps historical data (sales, movements) intact while removing
|
||||
the item from the active catalog and stock views.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, part_number, name FROM inventory WHERE id = %s", (item_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
|
||||
log_action(conn, 'INVENTORY_DELETE', 'inventory', item_id,
|
||||
old_value={'part_number': row[1], 'name': row[2]})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Item deleted', 'id': item_id})
|
||||
|
||||
|
||||
# ─── Bulk Image Import ─────────────────────────
|
||||
|
||||
@inventory_bp.route('/bulk-images', methods=['POST'])
|
||||
@@ -973,6 +1039,70 @@ def api_inventory_stats():
|
||||
})
|
||||
|
||||
|
||||
@inventory_bp.route('/summary', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def api_inventory_summary():
|
||||
"""Get high-level summary counts for the inventory dashboard badges."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
branch_id = getattr(g, 'branch_id', None)
|
||||
|
||||
where_branch = ""
|
||||
params = []
|
||||
if branch_id:
|
||||
where_branch = "AND i.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
# 1. Total active SKUs
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
WHERE i.is_active = true {where_branch}
|
||||
""", params.copy())
|
||||
total_skus = cur.fetchone()[0] or 0
|
||||
|
||||
# 2. Total inventory value (cost * stock)
|
||||
cur.execute(f"""
|
||||
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true {where_branch}
|
||||
""", params.copy())
|
||||
total_value = float(cur.fetchone()[0] or 0)
|
||||
|
||||
# 3. Low stock count (below min_stock)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*)
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true {where_branch}
|
||||
AND i.min_stock IS NOT NULL AND i.min_stock > 0
|
||||
AND COALESCE(s.stock, 0) < i.min_stock
|
||||
""", params.copy())
|
||||
low_stock = cur.fetchone()[0] or 0
|
||||
|
||||
# 4. No movement in last 60 days
|
||||
cutoff = datetime.utcnow() - timedelta(days=60)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*)
|
||||
FROM inventory i
|
||||
WHERE i.is_active = true {where_branch}
|
||||
AND i.id NOT IN (
|
||||
SELECT inventory_id FROM inventory_operations
|
||||
WHERE created_at > %s
|
||||
)
|
||||
""", params + [cutoff])
|
||||
no_movement = cur.fetchone()[0] or 0
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'total_skus': total_skus,
|
||||
'total_value': round(total_value, 2),
|
||||
'low_stock': low_stock,
|
||||
'no_movement': no_movement,
|
||||
})
|
||||
|
||||
|
||||
# ─── Alerts and History ────────────────────────
|
||||
|
||||
@inventory_bp.route('/alerts', methods=['GET'])
|
||||
@@ -1594,3 +1724,46 @@ def search_mye_endpoint():
|
||||
return jsonify({'data': results})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ─── Global Tier Discounts ───────────────────────
|
||||
|
||||
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_tier_discounts_endpoint():
|
||||
"""Return global tier discount percentages."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
discounts = _get_tier_discounts(conn)
|
||||
return jsonify({
|
||||
'data': [
|
||||
{'tier_id': 2, 'tier_name': 'Taller', 'discount_pct': discounts.get(2, 0)},
|
||||
{'tier_id': 3, 'tier_name': 'Mayoreo', 'discount_pct': discounts.get(3, 0)},
|
||||
]
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/tier-discounts', methods=['PUT'])
|
||||
@require_auth('config.edit_prices')
|
||||
def update_tier_discounts_endpoint():
|
||||
"""Update global tier discount percentages."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for tier_id in (2, 3):
|
||||
key = f'discount_pct_{tier_id}'
|
||||
if key in data:
|
||||
val = max(0, min(100, float(data[key])))
|
||||
cur.execute("""
|
||||
INSERT INTO tier_discounts (tier_id, tier_name, discount_pct)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (tier_id) DO UPDATE SET discount_pct = EXCLUDED.discount_pct
|
||||
""", (tier_id, 'Taller' if tier_id == 2 else 'Mayoreo', val))
|
||||
conn.commit()
|
||||
return jsonify({'message': 'Descuentos actualizados'})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -397,3 +397,30 @@ def get_sale_pdf(sale_id):
|
||||
'customer': customer,
|
||||
'cfdi': cfdi_info,
|
||||
})
|
||||
|
||||
|
||||
@invoicing_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('invoicing.read')
|
||||
def api_invoicing_stats():
|
||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
|
||||
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
|
||||
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
|
||||
FROM cfdi_queue
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'facturas': row[0] or 0,
|
||||
'notas_credito': row[1] or 0,
|
||||
'complementos': row[2] or 0,
|
||||
'cancelaciones': row[3] or 0,
|
||||
})
|
||||
|
||||
@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/inventory/listing/<int:wi_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def bodegas_with_listing(wi_id):
|
||||
"""Return bodegas stocking a specific seller listing (wi_id)."""
|
||||
def _do(master):
|
||||
data = mkt.get_bodegas_with_listing(master, wi_id)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
404
pos/blueprints/marketplace_external_bp.py
Normal file
404
pos/blueprints/marketplace_external_bp.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""MercadoLibre external marketplace REST endpoints.
|
||||
|
||||
Routes:
|
||||
Config
|
||||
GET /pos/api/marketplace-ext/config
|
||||
POST /pos/api/marketplace-ext/connect
|
||||
DELETE /pos/api/marketplace-ext/connect
|
||||
GET /pos/api/marketplace-ext/categories
|
||||
|
||||
Listings
|
||||
GET /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings
|
||||
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>
|
||||
|
||||
Orders
|
||||
GET /pos/api/marketplace-ext/orders
|
||||
GET /pos/api/marketplace-ext/orders/<id>
|
||||
POST /pos/api/marketplace-ext/orders/<id>/convert
|
||||
|
||||
Webhook (public)
|
||||
POST /pos/api/marketplace-ext/webhook/meli
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import marketplace_external_service as meli_svc
|
||||
from services.meli_service import MeliService, MeliAuthError
|
||||
|
||||
marketplace_ext_bp = Blueprint(
|
||||
"marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext"
|
||||
)
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _require_meli_manage():
|
||||
if not has_permission("marketplace.manage"):
|
||||
return jsonify({"error": "Missing permission: marketplace.manage"}), 403
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_config():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
# Never return tokens to frontend
|
||||
safe = {
|
||||
k: v for k, v in cfg.items()
|
||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||
}
|
||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||
return jsonify(safe)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||
@require_auth()
|
||||
def connect_meli():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = data.get("code")
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
redirect_uri = data.get("redirect_uri", "")
|
||||
|
||||
if not code or not client_id or not client_secret:
|
||||
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||
|
||||
try:
|
||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||
except MeliAuthError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
user_id = token_data.get("user_id")
|
||||
|
||||
# Validate token by fetching user
|
||||
svc = MeliService(access_token)
|
||||
try:
|
||||
user = svc.get_user()
|
||||
except MeliAuthError as e:
|
||||
return jsonify({"error": f"Invalid token: {e}"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.save_meli_config(conn, {
|
||||
"meli_access_token": access_token,
|
||||
"meli_refresh_token": refresh_token,
|
||||
"meli_user_id": str(user_id or user.get("id")),
|
||||
"meli_site_id": user.get("site_id", "MLM"),
|
||||
"meli_enabled": "true",
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"user_id": user_id or user.get("id"),
|
||||
"nickname": user.get("nickname"),
|
||||
"site_id": user.get("site_id"),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def disconnect_meli():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.delete_meli_config(conn)
|
||||
return jsonify({"ok": True})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/categories", methods=["GET"])
|
||||
@require_auth()
|
||||
def search_categories():
|
||||
q = request.args.get("q", "")
|
||||
site_id = request.args.get("site_id", "MLM")
|
||||
if not q or len(q) < 2:
|
||||
return jsonify({"categories": []})
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LISTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@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)
|
||||
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)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings", methods=["POST"])
|
||||
@require_auth()
|
||||
def create_listings():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.publish_items(
|
||||
conn,
|
||||
inventory_ids=inventory_ids,
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
)
|
||||
return jsonify(result), 201
|
||||
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("/listings/<int:listing_id>/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.sync_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
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("/listings/<int:listing_id>/pause", methods=["POST"])
|
||||
@require_auth()
|
||||
def pause_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.pause_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
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("/listings/<int:listing_id>/activate", methods=["POST"])
|
||||
@require_auth()
|
||||
def activate_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.activate_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
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("/listings/<int:listing_id>", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def delete_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.close_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 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)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders/<int:order_id>", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_order(order_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_order_detail(conn, order_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders/<int:order_id>/convert", methods=["POST"])
|
||||
@require_auth("pos.sell")
|
||||
def convert_order(order_id):
|
||||
data = request.get_json() or {}
|
||||
register_id = data.get("register_id")
|
||||
|
||||
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
|
||||
)
|
||||
return jsonify(result)
|
||||
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("/orders/<int:order_id>/status", methods=["POST"])
|
||||
@require_auth()
|
||||
def update_order_status_route(order_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get("status")
|
||||
if not new_status:
|
||||
return jsonify({"error": "status required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.update_order_status(conn, order_id, new_status)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WEBHOOK (public — no auth)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/webhook/meli", methods=["POST"])
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
"""
|
||||
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
|
||||
|
||||
if tenant_id and topic:
|
||||
try:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
except Exception as e:
|
||||
print(f"[ML Webhook] Failed to enqueue task: {e}")
|
||||
|
||||
return jsonify({"ok": True})
|
||||
@@ -15,21 +15,37 @@ from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import whatsapp_service
|
||||
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
|
||||
|
||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||
|
||||
|
||||
def _get_whatsapp_config(conn):
|
||||
"""Read WhatsApp bridge configuration from tenant_config.
|
||||
Returns dict with bridge_url, enabled, etc."""
|
||||
Falls back to global server config (config.py / env vars) when tenant
|
||||
has no explicit WhatsApp settings. This allows the shared bridge to work
|
||||
out of the box for all tenants.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
|
||||
config = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
||||
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
||||
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
||||
if enabled_raw == 'true':
|
||||
enabled = True
|
||||
elif enabled_raw == 'false':
|
||||
enabled = False
|
||||
else:
|
||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||
enabled = bool(bridge_url)
|
||||
|
||||
return {
|
||||
'bridge_url': config.get('whatsapp_bridge_url', ''),
|
||||
'bridge_key': config.get('whatsapp_bridge_key', ''),
|
||||
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||
'bridge_url': bridge_url,
|
||||
'bridge_key': bridge_key,
|
||||
'enabled': enabled,
|
||||
'phone_number': config.get('whatsapp_phone_number', ''),
|
||||
}
|
||||
|
||||
@@ -194,27 +210,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
|
||||
fallback_rows = _do_search(use_compat=False)
|
||||
|
||||
if not rows and not fallback_rows:
|
||||
# Truly nothing found — return a conversational message that doesn't kill the chat
|
||||
v_str = ""
|
||||
if vehicle and vehicle.get('brand'):
|
||||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
|
||||
|
||||
msg_parts = [
|
||||
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
|
||||
]
|
||||
if v_str:
|
||||
msg_parts.append(f"Para tu {v_str}, puedo:")
|
||||
else:
|
||||
msg_parts.append("Te puedo ayudar de estas formas:")
|
||||
msg_parts.extend([
|
||||
"",
|
||||
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
|
||||
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
|
||||
"• *Sugerir refaccionarias cercanas* — si es urgente",
|
||||
"",
|
||||
"¿Qué prefieres? O dime si quieres buscar otra parte."
|
||||
])
|
||||
return '\n'.join(msg_parts), None
|
||||
# Nothing found in local inventory — let the AI's original response stand.
|
||||
# The webhook will append a soft note instead of replacing the message.
|
||||
return None, None
|
||||
|
||||
# Use fallback rows if primary search returned nothing
|
||||
using_fallback = False
|
||||
@@ -366,6 +364,11 @@ def webhook():
|
||||
except Exception:
|
||||
tenant_id = None
|
||||
|
||||
# Prepare phone and reply target early
|
||||
reply_to = msg.get('jid') or msg['phone']
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
|
||||
tenant_conn = None
|
||||
master_conn = None
|
||||
inventory_context = None
|
||||
@@ -394,11 +397,34 @@ def webhook():
|
||||
print(f"[WA-AI] inventory_context failed: {e}")
|
||||
inventory_context = None
|
||||
|
||||
# 2c. Urgency detection — if customer signals urgency, add a note
|
||||
try:
|
||||
from services.part_kits import is_urgent, urgency_note
|
||||
if msg.get('text') and is_urgent(msg['text']):
|
||||
if inventory_context:
|
||||
inventory_context += urgency_note()
|
||||
else:
|
||||
inventory_context = urgency_note().strip()
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] urgency detection failed: {e}")
|
||||
|
||||
# 2d. Purchase history — append recent confirmed orders for this customer
|
||||
try:
|
||||
from services.part_kits import get_purchase_history
|
||||
history = get_purchase_history(clean_phone, tenant_conn)
|
||||
if history:
|
||||
if inventory_context:
|
||||
inventory_context += "\n\n" + history
|
||||
else:
|
||||
inventory_context = history
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Purchase history failed: {e}")
|
||||
|
||||
# 2b. Append previously-detected vehicle so the AI keeps context
|
||||
# even when we don't send full conversation history (Hermes is slow with it)
|
||||
try:
|
||||
from services.wa_quotation import get_vehicle
|
||||
saved_vehicle = get_vehicle(clean_phone)
|
||||
saved_vehicle = get_vehicle(tenant_conn, clean_phone)
|
||||
if saved_vehicle and inventory_context:
|
||||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
||||
if v_str:
|
||||
@@ -411,25 +437,68 @@ def webhook():
|
||||
print(f"[WA-AI] vehicle_context failed: {e}")
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] tenant connection failed: {e}")
|
||||
if tenant_conn:
|
||||
try:
|
||||
tenant_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Dispatch by media kind + quotation commands
|
||||
reply = None
|
||||
reply_to = msg.get('jid') or msg['phone']
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
|
||||
# ── Abandoned quotation follow-up ──
|
||||
# If customer has an active quote and hasn't interacted in 15+ min,
|
||||
# send a gentle nudge before processing their current message.
|
||||
try:
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(clean_phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
if tenant_conn:
|
||||
cur_fu = tenant_conn.cursor()
|
||||
cur_fu.execute(
|
||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||
(clean_phone, followup)
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur_fu.close()
|
||||
except Exception as fu_err:
|
||||
print(f"[WA-AI] Follow-up send failed: {fu_err}")
|
||||
|
||||
# ── Location message → nearest branch ──
|
||||
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
|
||||
from services.geo_branches import find_nearest_branch
|
||||
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
|
||||
if nearest:
|
||||
reply = (
|
||||
f"📍 *Sucursal más cercana:*\n\n"
|
||||
f"*{nearest['name']}*\n"
|
||||
f"📌 {nearest['address']}\n"
|
||||
f"📞 {nearest['phone']}\n"
|
||||
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
|
||||
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
|
||||
)
|
||||
else:
|
||||
reply = (
|
||||
"📍 Gracias por tu ubicación.\n\n"
|
||||
"Actualmente no tenemos sucursales registradas con coordenadas. "
|
||||
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
|
||||
)
|
||||
|
||||
# ── Check for quotation commands FIRST (before AI) ──
|
||||
if media_kind == 'text' and msg.get('text'):
|
||||
if not reply and media_kind == 'text' and msg.get('text'):
|
||||
from services.wa_quotation import (
|
||||
detect_quote_intent, get_open_quotation, create_quotation,
|
||||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||||
)
|
||||
from services.quote_image import generate_quote_image
|
||||
from services.whatsapp_service import send_image
|
||||
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
||||
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
||||
|
||||
if intent == 'add':
|
||||
last_part = get_last_shown_part(clean_phone)
|
||||
last_part = get_last_shown_part(tenant_conn, clean_phone)
|
||||
if not last_part:
|
||||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||||
elif tenant_conn:
|
||||
@@ -444,6 +513,14 @@ def webhook():
|
||||
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
||||
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
||||
)
|
||||
# Smart kit suggestion — cross-sell related parts
|
||||
try:
|
||||
from services.part_kits import build_kit_text
|
||||
kit_text = build_kit_text(last_part.get('name', ''))
|
||||
if kit_text:
|
||||
reply += kit_text
|
||||
except Exception as kit_err:
|
||||
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
|
||||
|
||||
elif intent == 'send':
|
||||
if tenant_conn:
|
||||
@@ -453,6 +530,32 @@ def webhook():
|
||||
reply = format_quotation_wa(detail)
|
||||
if not reply:
|
||||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||||
else:
|
||||
# Generate rich visual quote image and send it
|
||||
try:
|
||||
quote_items = []
|
||||
for it in detail.get('items', []):
|
||||
quote_items.append({
|
||||
'name': it.get('name', ''),
|
||||
'sku': it.get('sku', ''),
|
||||
'qty': it.get('quantity', 1),
|
||||
'price': float(it.get('unit_price', 0)),
|
||||
'total': float(it.get('total', 0)),
|
||||
})
|
||||
totals = {
|
||||
'subtotal': float(detail.get('subtotal', 0)),
|
||||
'tax': float(detail.get('tax', 0)),
|
||||
'total': float(detail.get('total', 0)),
|
||||
}
|
||||
tenant_name = tenant_config.get('business_name', 'Autopartes')
|
||||
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
|
||||
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
|
||||
if img_result.get('success'):
|
||||
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
|
||||
else:
|
||||
print(f"[WA-AI] Image send failed: {img_result}")
|
||||
except Exception as img_err:
|
||||
print(f"[WA-AI] Quote image generation failed: {img_err}")
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||||
|
||||
@@ -522,7 +625,7 @@ def webhook():
|
||||
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
|
||||
conversation_history = []
|
||||
if tenant_conn:
|
||||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
|
||||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=4)
|
||||
if conversation_history:
|
||||
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
|
||||
|
||||
@@ -570,6 +673,26 @@ def webhook():
|
||||
'Puedes escribirme el mensaje?')
|
||||
|
||||
elif msg.get('text'):
|
||||
txt = msg['text'].strip().lower()
|
||||
# Quick welcome menu for new customers with no vehicle
|
||||
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
|
||||
if is_greeting:
|
||||
try:
|
||||
from services.wa_quotation import get_vehicle
|
||||
veh = get_vehicle(tenant_conn, clean_phone)
|
||||
if not veh:
|
||||
reply = (
|
||||
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
|
||||
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
|
||||
"1️⃣ *Marca, modelo y año* de tu vehículo\n"
|
||||
"2️⃣ La *parte* que necesitas\n"
|
||||
"3️⃣ O escribe *menú* para ver opciones\n\n"
|
||||
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not reply:
|
||||
# Plain text message — standard chatbot flow
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
|
||||
@@ -584,7 +707,7 @@ def webhook():
|
||||
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
|
||||
try:
|
||||
from services.wa_quotation import set_vehicle
|
||||
set_vehicle(clean_phone, vehicle)
|
||||
set_vehicle(tenant_conn, clean_phone, vehicle)
|
||||
except Exception as veh_err:
|
||||
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
|
||||
|
||||
@@ -593,10 +716,14 @@ def webhook():
|
||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
|
||||
if enrichment:
|
||||
reply = reply + '\n\n' + enrichment
|
||||
elif not found_part and vehicle and vehicle.get('brand'):
|
||||
# Only say "not in stock" when we have a specific vehicle
|
||||
# and still found nothing. Otherwise let the AI ask for vehicle info.
|
||||
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
|
||||
# Track the found part so "cotizar" can add it
|
||||
if found_part:
|
||||
from services.wa_quotation import set_last_shown_part
|
||||
set_last_shown_part(clean_phone, found_part)
|
||||
set_last_shown_part(tenant_conn, clean_phone, found_part)
|
||||
except Exception as enrich_err:
|
||||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
|
||||
# gthread workers handle multiple concurrent requests per worker via threads.
|
||||
# Ideal for I/O-bound Flask apps with DB queries.
|
||||
# 4 workers × 4 threads = 16 concurrent requests.
|
||||
workers = 4
|
||||
workers = 8
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
worker_connections = 1000
|
||||
|
||||
@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
if payload.get('type') != 'pos_access':
|
||||
if payload.get('type') not in ('pos_access', 'access'):
|
||||
return jsonify({'error': 'Invalid token type'}), 401
|
||||
|
||||
g.tenant_id = payload['tenant_id']
|
||||
|
||||
@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true",
|
||||
"SELECT id, name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true",
|
||||
(subdomain,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
91
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
91
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- v3.3 — Marketplace accepts any part number (seller listings)
|
||||
-- Target: nexus_autoparts (master DB)
|
||||
-- Date: 2026-05-17
|
||||
--
|
||||
-- Makes warehouse_inventory part_id nullable and adds seller-defined
|
||||
-- fields so any seller can list parts that don't exist in the OEM catalog.
|
||||
-- Existing OEM-matched listings are untouched.
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ─────────────
|
||||
|
||||
ALTER TABLE warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300),
|
||||
ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER;
|
||||
|
||||
-- Make part_id nullable so seller listings (without catalog match) can exist
|
||||
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
|
||||
|
||||
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
|
||||
|
||||
-- The old constraint was on (user_id, part_id, warehouse_location).
|
||||
-- We replace it with two partial unique indexes:
|
||||
-- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||
-- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
||||
|
||||
ALTER TABLE warehouse_inventory
|
||||
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
|
||||
|
||||
DROP INDEX IF EXISTS idx_wi_unique_composite;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
|
||||
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
|
||||
WHERE part_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
|
||||
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
-- Ensure every row has either part_id or seller_part_number
|
||||
ALTER TABLE warehouse_inventory
|
||||
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
|
||||
|
||||
ALTER TABLE warehouse_inventory
|
||||
ADD CONSTRAINT chk_wi_part_or_seller
|
||||
CHECK (
|
||||
(part_id IS NOT NULL AND seller_part_number IS NULL)
|
||||
OR
|
||||
(part_id IS NULL AND seller_part_number IS NOT NULL)
|
||||
);
|
||||
|
||||
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
|
||||
ON warehouse_inventory (bodega_id, seller_part_number)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
|
||||
ON warehouse_inventory (seller_category)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
-- GIN index for text search on seller listings
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
|
||||
ON warehouse_inventory
|
||||
USING gin (to_tsvector('spanish',
|
||||
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
|
||||
))
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
-- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ─────────────────
|
||||
|
||||
ALTER TABLE purchase_order_items
|
||||
ALTER COLUMN part_id DROP NOT NULL;
|
||||
|
||||
-- Add a flag so seller listings can be distinguished in POs
|
||||
ALTER TABLE purchase_order_items
|
||||
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
|
||||
|
||||
-- Existing rows should have part_id set and seller_part_number NULL.
|
||||
-- If any row violates the new check, this will fail loudly.
|
||||
UPDATE warehouse_inventory
|
||||
SET seller_part_number = NULL
|
||||
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
|
||||
|
||||
UPDATE warehouse_inventory
|
||||
SET part_id = NULL
|
||||
WHERE part_id IS NULL AND seller_part_number IS NULL;
|
||||
110
pos/migrations/v3.4_meli_integration.sql
Normal file
110
pos/migrations/v3.4_meli_integration.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- ============================================================
|
||||
-- v3.4 MercadoLibre Integration
|
||||
-- ============================================================
|
||||
-- Adds tables for external marketplace listings, orders,
|
||||
-- order items, and a generic sync queue.
|
||||
-- All tables live in the tenant DB.
|
||||
-- ============================================================
|
||||
|
||||
-- Listings published on MercadoLibre (extensible to Amazon later)
|
||||
CREATE TABLE IF NOT EXISTS marketplace_listings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||
external_item_id VARCHAR(50) NOT NULL,
|
||||
external_status VARCHAR(30) DEFAULT 'active',
|
||||
external_permalink TEXT,
|
||||
title TEXT,
|
||||
meli_category_id VARCHAR(30),
|
||||
publish_price NUMERIC(12,2),
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
sync_errors TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inventory
|
||||
ON marketplace_listings(inventory_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_external
|
||||
ON marketplace_listings(external_item_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_marketplace_listings_unique
|
||||
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
|
||||
|
||||
-- Orders received from MercadoLibre
|
||||
CREATE TABLE IF NOT EXISTS marketplace_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||
external_order_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
external_status VARCHAR(30) NOT NULL,
|
||||
buyer_name VARCHAR(200),
|
||||
buyer_email VARCHAR(200),
|
||||
buyer_phone VARCHAR(50),
|
||||
buyer_nickname VARCHAR(100),
|
||||
shipping_address JSONB,
|
||||
total_amount NUMERIC(12,2),
|
||||
shipping_cost NUMERIC(12,2),
|
||||
meli_shipping_id VARCHAR(50),
|
||||
nexus_sale_id INTEGER REFERENCES sales(id),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
raw_json JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_status
|
||||
ON marketplace_orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_external
|
||||
ON marketplace_orders(external_order_id);
|
||||
|
||||
-- Items inside a marketplace order
|
||||
CREATE TABLE IF NOT EXISTS marketplace_order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
marketplace_order_id INTEGER REFERENCES marketplace_orders(id) ON DELETE CASCADE,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
external_item_id VARCHAR(50),
|
||||
title VARCHAR(300),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price NUMERIC(12,2),
|
||||
total_price NUMERIC(12,2),
|
||||
listing_id INTEGER REFERENCES marketplace_listings(id)
|
||||
);
|
||||
|
||||
-- Generic sync queue (reusable for future Amazon integration)
|
||||
CREATE TABLE IF NOT EXISTS marketplace_sync_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
channel VARCHAR(20) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
payload JSONB,
|
||||
error_message TEXT,
|
||||
retry_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_sync_queue_pending
|
||||
ON marketplace_sync_queue(status, channel) WHERE status = 'pending';
|
||||
|
||||
-- Add source column to sales to track origin (POS, ML, Amazon, etc.)
|
||||
-- If the column already exists from another migration, do nothing.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sales' AND column_name = 'source'
|
||||
) THEN
|
||||
ALTER TABLE sales ADD COLUMN source VARCHAR(30) DEFAULT 'pos';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sales' AND column_name = 'external_order_id'
|
||||
) THEN
|
||||
ALTER TABLE sales ADD COLUMN external_order_id VARCHAR(50);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
|
||||
return None
|
||||
|
||||
|
||||
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes.
|
||||
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
|
||||
|
||||
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
|
||||
|
||||
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
|
||||
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
|
||||
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes.
|
||||
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
|
||||
|
||||
REGLAS DE VENTA AVANZADAS:
|
||||
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
|
||||
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
|
||||
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
|
||||
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
|
||||
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
|
||||
3. MANEJO DE OBJECIONES:
|
||||
- "Esta caro" → "Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
|
||||
- "Voy a checar en otro lado" → "Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
|
||||
- "Lo necesito para hoy" / "Urgente" → "Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
|
||||
- "No se si sea esa" → "No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
|
||||
- "Solo estoy cotizando" → "Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
|
||||
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
|
||||
- "¿Te lo aparto?"
|
||||
- "¿Lo mando a tu taller o lo pasas a recoger?"
|
||||
- "¿Con esto quedas o necesitas algo mas?"
|
||||
- "¿Te armo el paquete completo? Sale mejor que por separado."
|
||||
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
|
||||
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
|
||||
|
||||
TRADUCCIONES search_query (EN INGLES):
|
||||
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
|
||||
|
||||
FORMATO:
|
||||
- search_query EN INGLES. NUNCA null si pide algo.
|
||||
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
|
||||
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
|
||||
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
|
||||
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
|
||||
- Termina SIEMPRE con una pregunta de cierre.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
||||
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
||||
GROUP BY i.brand
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 15
|
||||
LIMIT 10
|
||||
""", params)
|
||||
brands = cur.fetchall()
|
||||
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
|
||||
|
||||
# Top categories with counts
|
||||
cur.execute(f"""
|
||||
SELECT c.name, COUNT(*) as cnt
|
||||
FROM inventory i
|
||||
JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
|
||||
GROUP BY c.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""", params)
|
||||
categories = cur.fetchall()
|
||||
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
|
||||
|
||||
# Products with low stock (<=3)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
"CONTEXTO DEL INVENTARIO:",
|
||||
f"Este negocio tiene {total} productos en inventario.",
|
||||
]
|
||||
if category_list:
|
||||
lines.append(f"Categorias principales: {category_list}")
|
||||
if brand_list:
|
||||
lines.append(f"Marcas disponibles: {brand_list}")
|
||||
lines.append(f"Marcas top: {brand_list}")
|
||||
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
|
||||
]
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# Try Hermes first for vision (if enabled), fallback to OpenRouter
|
||||
# Vision backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
||||
|
||||
@@ -339,10 +385,10 @@ def classify_part(part_number):
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
# Try Hermes first (if enabled), fallback to OpenRouter
|
||||
# Backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
|
||||
|
||||
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
|
||||
last_error = None
|
||||
|
||||
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
|
||||
# Build backend list: QWEN first, then OpenRouter fallback
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
|
||||
if OPENROUTER_API_KEY:
|
||||
for m in FALLBACK_MODELS:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
||||
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
if conversation_history:
|
||||
msgs.extend(conversation_history)
|
||||
msgs.append({"role": "user", "content": user_message})
|
||||
|
||||
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
|
||||
max_retries = 3 if url == QWEN_CHAT_URL else 1
|
||||
result = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
||||
if result is not None:
|
||||
break
|
||||
if attempt < max_retries:
|
||||
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
|
||||
_time_chat.sleep(2)
|
||||
|
||||
if result is None:
|
||||
if url == QWEN_CHAT_URL:
|
||||
print(f"[AI] QWEN failed, trying Hermes fallback...")
|
||||
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
|
||||
last_error = "qwen_failed"
|
||||
elif url == HERMES_CHAT_URL:
|
||||
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
|
||||
last_error = "hermes_timeout"
|
||||
else:
|
||||
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||
last_error = "rate_limit"
|
||||
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
# All models exhausted — DON'T cache errors, we want retries next time
|
||||
if last_error == "rate_limit":
|
||||
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||
if last_error == "hermes_timeout":
|
||||
if last_error == "qwen_failed":
|
||||
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
|
||||
return {
|
||||
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
|
||||
|
||||
@@ -230,29 +230,42 @@ def get_engines(master_conn, model_id, year_id):
|
||||
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
|
||||
|
||||
|
||||
def get_categories(master_conn, mye_id):
|
||||
def get_categories(master_conn, mye_id, allowed_brands=None):
|
||||
"""Get part categories that have parts for this vehicle (mye_id).
|
||||
|
||||
Uses a subquery on vehicle_parts filtered by mye_id (indexed),
|
||||
then JOINs through parts -> part_groups -> part_categories.
|
||||
Uses COUNT with a safety LIMIT on the subquery.
|
||||
|
||||
If allowed_brands is provided, only counts parts that have at least one
|
||||
aftermarket equivalent from those manufacturers.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
brand_filter = ""
|
||||
params = [mye_id]
|
||||
if allowed_brands:
|
||||
brand_filter = """AND EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap2
|
||||
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||
)"""
|
||||
params.append(allowed_brands)
|
||||
cur.execute(f"""
|
||||
SELECT pc.id_part_category,
|
||||
COALESCE(pc.name_es, pc.name_part_category) AS name,
|
||||
sub.cnt
|
||||
FROM (
|
||||
SELECT pg.category_id, COUNT(*) AS cnt
|
||||
SELECT pg.category_id, COUNT(DISTINCT p.id_part) AS cnt
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{brand_filter}
|
||||
GROUP BY pg.category_id
|
||||
) sub
|
||||
JOIN part_categories pc ON pc.id_part_category = sub.category_id
|
||||
ORDER BY name
|
||||
""", (mye_id,))
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
|
||||
|
||||
56
pos/services/geo_branches.py
Normal file
56
pos/services/geo_branches.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
"""Calculate the great-circle distance between two points on Earth in km."""
|
||||
R = 6371.0 # Earth radius in km
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def find_nearest_branch(tenant_conn, latitude, longitude):
|
||||
"""
|
||||
Find the nearest active branch with coordinates.
|
||||
Returns a dict with branch info + distance_km, or None.
|
||||
"""
|
||||
if not tenant_conn or latitude is None or longitude is None:
|
||||
return None
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, address, phone, latitude, longitude
|
||||
FROM branches
|
||||
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
"""
|
||||
)
|
||||
branches = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
nearest = None
|
||||
min_dist = float('inf')
|
||||
|
||||
for row in branches:
|
||||
bid, name, address, phone, b_lat, b_lon = row
|
||||
if b_lat is None or b_lon is None:
|
||||
continue
|
||||
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest = {
|
||||
'id': bid,
|
||||
'name': name,
|
||||
'address': address or '',
|
||||
'phone': phone or '',
|
||||
'latitude': float(b_lat),
|
||||
'longitude': float(b_lon),
|
||||
'distance_km': round(dist, 1),
|
||||
}
|
||||
|
||||
return nearest
|
||||
978
pos/services/marketplace_external_service.py
Normal file
978
pos/services/marketplace_external_service.py
Normal file
@@ -0,0 +1,978 @@
|
||||
"""Business logic for MercadoLibre external marketplace integration.
|
||||
|
||||
Depends on:
|
||||
- meli_service.py (HTTP client)
|
||||
- pos_engine.py (sale creation)
|
||||
- inventory_engine.py (stock queries)
|
||||
- image_service.py (image URLs)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from services.meli_service import MeliService, MeliError, MeliAuthError
|
||||
from services.pos_engine import process_sale, calculate_totals
|
||||
from services.inventory_engine import get_stock, get_stock_bulk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG HELPERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
MELI_CONFIG_KEYS = [
|
||||
"meli_access_token",
|
||||
"meli_refresh_token",
|
||||
"meli_user_id",
|
||||
"meli_site_id",
|
||||
"meli_enabled",
|
||||
"meli_auto_publish",
|
||||
"meli_sync_interval_min",
|
||||
"meli_order_sync_interval_min",
|
||||
"meli_default_category_id",
|
||||
"meli_shipping_mode",
|
||||
"meli_client_id",
|
||||
"meli_client_secret",
|
||||
]
|
||||
|
||||
|
||||
def _get_config_value(cur, key: str, default=None):
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else default
|
||||
|
||||
|
||||
def get_meli_config(tenant_conn) -> dict:
|
||||
"""Read ML config from tenant_config."""
|
||||
cur = tenant_conn.cursor()
|
||||
cfg = {}
|
||||
for key in MELI_CONFIG_KEYS:
|
||||
cfg[key] = _get_config_value(cur, key)
|
||||
cur.close()
|
||||
# Normalize booleans
|
||||
cfg["meli_enabled"] = (cfg.get("meli_enabled") or "").lower() == "true"
|
||||
cfg["meli_auto_publish"] = (cfg.get("meli_auto_publish") or "").lower() == "true"
|
||||
cfg["meli_sync_interval_min"] = int(cfg.get("meli_sync_interval_min") or 15)
|
||||
cfg["meli_order_sync_interval_min"] = int(cfg.get("meli_order_sync_interval_min") or 5)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_meli_config(tenant_conn, updates: dict) -> None:
|
||||
"""Upsert ML config keys into tenant_config."""
|
||||
cur = tenant_conn.cursor()
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
""",
|
||||
(key, str(value)),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def delete_meli_config(tenant_conn) -> None:
|
||||
"""Remove all ML config keys."""
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM tenant_config WHERE key LIKE 'meli_%'"
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def _get_meli_service(cfg: dict) -> Optional[MeliService]:
|
||||
"""Build MeliService from config dict."""
|
||||
token = cfg.get("meli_access_token")
|
||||
if not token:
|
||||
return None
|
||||
return MeliService(
|
||||
access_token=token,
|
||||
refresh_token=cfg.get("meli_refresh_token"),
|
||||
client_id=cfg.get("meli_client_id"),
|
||||
client_secret=cfg.get("meli_client_secret"),
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ITEM PAYLOAD BUILDER
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _extract_meli_error(err: MeliError) -> str:
|
||||
"""Extract the most useful error message from a MeliError response."""
|
||||
base = str(err)
|
||||
body = err.response_body
|
||||
if not body:
|
||||
return base
|
||||
try:
|
||||
data = json.loads(body)
|
||||
msg = data.get("message") or data.get("error")
|
||||
causes = data.get("cause", [])
|
||||
if causes and isinstance(causes, list):
|
||||
cause_msgs = [c.get("message") for c in causes if c.get("message")]
|
||||
if cause_msgs:
|
||||
msg = (msg + " | " if msg else "") + "; ".join(cause_msgs)
|
||||
if msg:
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def build_item_payload(
|
||||
inventory_row: dict,
|
||||
images: list[str],
|
||||
meli_category_id: str,
|
||||
price: float,
|
||||
stock: int,
|
||||
shipping_mode: str = "me2",
|
||||
listing_type_id: str = "gold_special",
|
||||
) -> dict:
|
||||
"""Convert a Nexus inventory row into a MercadoLibre item payload."""
|
||||
title = f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
|
||||
# ML title limit is 60 chars; truncate smartly
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"category_id": meli_category_id,
|
||||
"price": round(float(price), 2),
|
||||
"currency_id": "MXN",
|
||||
"available_quantity": max(int(stock), 0),
|
||||
"buying_mode": "buy_it_now",
|
||||
"listing_type_id": listing_type_id,
|
||||
"condition": "new",
|
||||
"pictures": [{"source": url} for url in images if url],
|
||||
"shipping": {"mode": shipping_mode},
|
||||
"attributes": [],
|
||||
}
|
||||
|
||||
if inventory_row.get("brand"):
|
||||
payload["attributes"].append(
|
||||
{"id": "BRAND", "value_name": inventory_row["brand"]}
|
||||
)
|
||||
|
||||
if inventory_row.get("part_number"):
|
||||
payload["attributes"].append(
|
||||
{"id": "PART_NUMBER", "value_name": inventory_row["part_number"]}
|
||||
)
|
||||
|
||||
# Vehicle compatibility as attributes (if available)
|
||||
vehicle_compat = inventory_row.get("vehicle_compatibility")
|
||||
if vehicle_compat:
|
||||
if isinstance(vehicle_compat, str):
|
||||
try:
|
||||
vehicle_compat = json.loads(vehicle_compat)
|
||||
except Exception:
|
||||
vehicle_compat = None
|
||||
if isinstance(vehicle_compat, list) and vehicle_compat:
|
||||
first = vehicle_compat[0]
|
||||
if isinstance(first, dict):
|
||||
if first.get("brand"):
|
||||
payload["attributes"].append(
|
||||
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
|
||||
)
|
||||
if first.get("model"):
|
||||
payload["attributes"].append(
|
||||
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LISTINGS CRUD
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def publish_items(
|
||||
tenant_conn,
|
||||
inventory_ids: list[int],
|
||||
meli_category_id: str,
|
||||
listing_type_id: str = "gold_special",
|
||||
shipping_mode: str = "me2",
|
||||
) -> dict:
|
||||
"""Publish one or more inventory items to MercadoLibre.
|
||||
|
||||
Returns summary dict with success/failure per item.
|
||||
"""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
|
||||
# Batch fetch inventory rows
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
|
||||
image_url, unit, is_active
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
""",
|
||||
(inventory_ids,),
|
||||
)
|
||||
rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
# Batch fetch stock
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
|
||||
results = {"success": [], "failed": []}
|
||||
|
||||
for inv_id in inventory_ids:
|
||||
row = rows.get(inv_id)
|
||||
if not row:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "Not found or inactive"})
|
||||
continue
|
||||
|
||||
inv = {
|
||||
"id": row[0],
|
||||
"part_number": row[1],
|
||||
"name": row[2],
|
||||
"brand": row[3],
|
||||
"price_1": float(row[4]) if row[4] else 0,
|
||||
"vehicle_compatibility": row[5],
|
||||
"image_url": row[6],
|
||||
"unit": row[7],
|
||||
}
|
||||
|
||||
stock = stock_map.get(inv_id, 0)
|
||||
if stock <= 0:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "Sin stock disponible"})
|
||||
continue
|
||||
|
||||
if inv["price_1"] <= 0:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "El precio debe ser mayor a 0"})
|
||||
continue
|
||||
|
||||
# Build image list
|
||||
images = []
|
||||
if inv.get("image_url"):
|
||||
images.append(inv["image_url"])
|
||||
# TODO: fetch additional images from a separate table if we have gallery support
|
||||
|
||||
if not images:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
|
||||
continue
|
||||
|
||||
payload = build_item_payload(
|
||||
inv, images, meli_category_id, inv["price_1"], stock,
|
||||
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
|
||||
)
|
||||
|
||||
try:
|
||||
ml_item = svc.create_item(payload)
|
||||
external_item_id = ml_item.get("id")
|
||||
permalink = ml_item.get("permalink")
|
||||
|
||||
# Persist listing
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_listings
|
||||
(inventory_id, channel, external_item_id, external_status,
|
||||
external_permalink, title, meli_category_id, publish_price,
|
||||
last_sync_at, sync_errors, is_active)
|
||||
VALUES (%s, 'mercadolibre', %s, 'active', %s, %s, %s, %s, NOW(), NULL, true)
|
||||
ON CONFLICT (inventory_id, channel) WHERE is_active = true
|
||||
DO UPDATE SET
|
||||
external_item_id = EXCLUDED.external_item_id,
|
||||
external_status = EXCLUDED.external_status,
|
||||
external_permalink = EXCLUDED.external_permalink,
|
||||
title = EXCLUDED.title,
|
||||
meli_category_id = EXCLUDED.meli_category_id,
|
||||
publish_price = EXCLUDED.publish_price,
|
||||
last_sync_at = NOW(),
|
||||
sync_errors = NULL,
|
||||
is_active = true
|
||||
""",
|
||||
(
|
||||
inv_id,
|
||||
external_item_id,
|
||||
permalink,
|
||||
payload["title"],
|
||||
meli_category_id,
|
||||
inv["price_1"],
|
||||
),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
results["success"].append(
|
||||
{"inventory_id": inv_id, "external_item_id": external_item_id, "permalink": permalink}
|
||||
)
|
||||
except MeliError as e:
|
||||
tenant_conn.rollback()
|
||||
err_msg = _extract_meli_error(e)
|
||||
logger.warning("ML publish failed for inventory_id=%s: %s | payload=%s | response=%s", inv_id, err_msg, json.dumps(payload), e.response_body)
|
||||
results["failed"].append({"inventory_id": inv_id, "error": err_msg})
|
||||
except Exception as e:
|
||||
tenant_conn.rollback()
|
||||
logger.exception("Unexpected error publishing inventory_id=%s", inv_id)
|
||||
results["failed"].append({"inventory_id": inv_id, "error": str(e)})
|
||||
|
||||
cur.close()
|
||||
return results
|
||||
|
||||
|
||||
def get_listings(tenant_conn, page: int = 1, per_page: int = 50, status: str = None):
|
||||
cur = tenant_conn.cursor()
|
||||
where = ["1=1"]
|
||||
params = []
|
||||
if status:
|
||||
where.append("external_status = %s")
|
||||
params.append(status)
|
||||
|
||||
count_sql = f"SELECT COUNT(*) FROM marketplace_listings WHERE {' AND '.join(where)}"
|
||||
cur.execute(count_sql, params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
sql = f"""
|
||||
SELECT l.id, l.inventory_id, l.external_item_id, l.external_status,
|
||||
l.external_permalink, l.title, l.meli_category_id, l.publish_price,
|
||||
l.last_sync_at, l.sync_errors, l.is_active, l.created_at,
|
||||
i.part_number, i.name, i.price_1, i.brand
|
||||
FROM marketplace_listings l
|
||||
JOIN inventory i ON i.id = l.inventory_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cur.execute(sql, params + [per_page, (page - 1) * per_page])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"inventory_id": r[1],
|
||||
"external_item_id": r[2],
|
||||
"external_status": r[3],
|
||||
"external_permalink": r[4],
|
||||
"title": r[5],
|
||||
"meli_category_id": r[6],
|
||||
"publish_price": float(r[7]) if r[7] else None,
|
||||
"last_sync_at": str(r[8]) if r[8] else None,
|
||||
"sync_errors": r[9],
|
||||
"is_active": r[10],
|
||||
"created_at": str(r[11]),
|
||||
"part_number": r[12],
|
||||
"inventory_name": r[13],
|
||||
"current_price": float(r[14]) if r[14] else None,
|
||||
"brand": r[15],
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
def sync_listing(tenant_conn, listing_id: int) -> dict:
|
||||
"""Force a manual sync of stock/price for a single listing."""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, inventory_id, external_item_id, meli_category_id
|
||||
FROM marketplace_listings WHERE id = %s AND is_active = true
|
||||
""",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
listing = {
|
||||
"id": row[0],
|
||||
"inventory_id": row[1],
|
||||
"external_item_id": row[2],
|
||||
"meli_category_id": row[3],
|
||||
}
|
||||
|
||||
# Get current stock/price
|
||||
cur.execute(
|
||||
"SELECT price_1 FROM inventory WHERE id = %s",
|
||||
(listing["inventory_id"],),
|
||||
)
|
||||
price_row = cur.fetchone()
|
||||
current_price = float(price_row[0]) if price_row and price_row[0] else 0
|
||||
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
current_stock = stock_map.get(listing["inventory_id"], 0)
|
||||
|
||||
try:
|
||||
svc.update_item(
|
||||
listing["external_item_id"],
|
||||
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE marketplace_listings
|
||||
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(current_price, listing_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "price": current_price, "stock": current_stock}
|
||||
except MeliError as e:
|
||||
tenant_conn.rollback()
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
|
||||
(str(e)[:500], listing_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
raise
|
||||
|
||||
|
||||
def pause_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.pause_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'paused' WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "paused"}
|
||||
|
||||
|
||||
def activate_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.activate_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'active' WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "active"}
|
||||
|
||||
|
||||
def close_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.close_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'closed', is_active = false WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "closed"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def fetch_and_save_orders(tenant_conn, date_from: Optional[str] = None) -> dict:
|
||||
"""Pull orders from ML and upsert into marketplace_orders."""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
user_id = cfg.get("meli_user_id")
|
||||
if not svc or not user_id:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
ml_resp = svc.get_orders(user_id, status="paid", date_from=date_from)
|
||||
orders = ml_resp.get("results", [])
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for order_summary in orders:
|
||||
order_id = order_summary.get("id")
|
||||
if not order_id:
|
||||
continue
|
||||
|
||||
# Fetch full order detail
|
||||
try:
|
||||
full = svc.get_order(str(order_id))
|
||||
except MeliError:
|
||||
continue
|
||||
|
||||
external_status = full.get("status")
|
||||
buyer = full.get("buyer", {})
|
||||
shipping = full.get("shipping", {})
|
||||
order_items = full.get("order_items", [])
|
||||
|
||||
# Build shipping address JSON
|
||||
shipping_address = None
|
||||
if shipping:
|
||||
shipping_address = {
|
||||
"id": shipping.get("id"),
|
||||
"status": shipping.get("status"),
|
||||
"tracking_number": shipping.get("tracking_number"),
|
||||
"shipping_method": shipping.get("shipping_option", {}).get("name"),
|
||||
}
|
||||
|
||||
total_amount = full.get("total_amount")
|
||||
shipping_cost = shipping.get("cost") if shipping else None
|
||||
|
||||
# Upsert order
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_orders
|
||||
(channel, external_order_id, external_status, buyer_name,
|
||||
buyer_email, buyer_phone, buyer_nickname, shipping_address,
|
||||
total_amount, shipping_cost, meli_shipping_id, raw_json, updated_at)
|
||||
VALUES ('mercadolibre', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (external_order_id) DO UPDATE SET
|
||||
external_status = EXCLUDED.external_status,
|
||||
buyer_name = EXCLUDED.buyer_name,
|
||||
buyer_email = EXCLUDED.buyer_email,
|
||||
buyer_phone = EXCLUDED.buyer_phone,
|
||||
shipping_address = EXCLUDED.shipping_address,
|
||||
total_amount = EXCLUDED.total_amount,
|
||||
shipping_cost = EXCLUDED.shipping_cost,
|
||||
meli_shipping_id = EXCLUDED.meli_shipping_id,
|
||||
raw_json = EXCLUDED.raw_json,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
str(order_id),
|
||||
external_status,
|
||||
buyer.get("first_name", "") + " " + buyer.get("last_name", ""),
|
||||
buyer.get("email"),
|
||||
buyer.get("phone", {}).get("number"),
|
||||
buyer.get("nickname"),
|
||||
json.dumps(shipping_address) if shipping_address else None,
|
||||
total_amount,
|
||||
shipping_cost,
|
||||
str(shipping.get("id")) if shipping else None,
|
||||
json.dumps(full),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
mpo_id = row[0] if row else None
|
||||
|
||||
if mpo_id:
|
||||
# Check if this was insert or update
|
||||
cur.execute(
|
||||
"SELECT created_at FROM marketplace_orders WHERE id = %s",
|
||||
(mpo_id,),
|
||||
)
|
||||
created_at = cur.fetchone()[0]
|
||||
# Simple heuristic: if updated_at == created_at (within 1s), it's new
|
||||
is_new = True # ON CONFLICT always returns id; we count as updated for simplicity
|
||||
updated += 1
|
||||
|
||||
# Upsert order items
|
||||
if mpo_id and order_items:
|
||||
# Clear old items and re-insert
|
||||
cur.execute(
|
||||
"DELETE FROM marketplace_order_items WHERE marketplace_order_id = %s",
|
||||
(mpo_id,),
|
||||
)
|
||||
for it in order_items:
|
||||
item_data = it.get("item", {})
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_order_items
|
||||
(marketplace_order_id, external_item_id, title,
|
||||
quantity, unit_price, total_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
mpo_id,
|
||||
item_data.get("id"),
|
||||
item_data.get("title"),
|
||||
it.get("quantity"),
|
||||
it.get("unit_price"),
|
||||
it.get("full_unit_price"),
|
||||
),
|
||||
)
|
||||
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"processed": len(orders), "updated": updated}
|
||||
|
||||
|
||||
def get_orders(tenant_conn, page=1, per_page=50, status=None):
|
||||
cur = tenant_conn.cursor()
|
||||
where = ["1=1"]
|
||||
params = []
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM marketplace_orders WHERE {' AND '.join(where)}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT o.id, o.external_order_id, o.external_status, o.buyer_name,
|
||||
o.buyer_nickname, o.total_amount, o.status, o.created_at,
|
||||
o.nexus_sale_id
|
||||
FROM marketplace_orders o
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [per_page, (page - 1) * per_page],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"external_order_id": r[1],
|
||||
"external_status": r[2],
|
||||
"buyer_name": r[3],
|
||||
"buyer_nickname": r[4],
|
||||
"total_amount": float(r[5]) if r[5] else None,
|
||||
"status": r[6],
|
||||
"created_at": str(r[7]),
|
||||
"nexus_sale_id": r[8],
|
||||
})
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
def get_order_detail(tenant_conn, order_id: int) -> dict:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, external_order_id, external_status, buyer_name, buyer_email,
|
||||
buyer_phone, buyer_nickname, shipping_address, total_amount,
|
||||
shipping_cost, meli_shipping_id, nexus_sale_id, status, notes,
|
||||
raw_json, created_at, updated_at
|
||||
FROM marketplace_orders WHERE id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Order not found")
|
||||
|
||||
order = {
|
||||
"id": row[0],
|
||||
"external_order_id": row[1],
|
||||
"external_status": row[2],
|
||||
"buyer_name": row[3],
|
||||
"buyer_email": row[4],
|
||||
"buyer_phone": row[5],
|
||||
"buyer_nickname": row[6],
|
||||
"shipping_address": row[7],
|
||||
"total_amount": float(row[8]) if row[8] else None,
|
||||
"shipping_cost": float(row[9]) if row[9] else None,
|
||||
"meli_shipping_id": row[10],
|
||||
"nexus_sale_id": row[11],
|
||||
"status": row[12],
|
||||
"notes": row[13],
|
||||
"raw_json": row[14],
|
||||
"created_at": str(row[15]),
|
||||
"updated_at": str(row[16]),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, inventory_id, external_item_id, title, quantity,
|
||||
unit_price, total_price
|
||||
FROM marketplace_order_items WHERE marketplace_order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"inventory_id": r[1],
|
||||
"external_item_id": r[2],
|
||||
"title": r[3],
|
||||
"quantity": r[4],
|
||||
"unit_price": float(r[5]) if r[5] else None,
|
||||
"total_price": float(r[6]) if r[6] else None,
|
||||
})
|
||||
order["items"] = items
|
||||
cur.close()
|
||||
return order
|
||||
|
||||
|
||||
def update_order_status(tenant_conn, order_id: int, new_status: str) -> dict:
|
||||
valid = {"pending", "confirmed", "packed", "shipped", "delivered", "cancelled", "rejected"}
|
||||
if new_status not in valid:
|
||||
raise ValueError(f"Invalid status. Allowed: {valid}")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s RETURNING external_order_id",
|
||||
(new_status, order_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
if not row:
|
||||
raise ValueError("Order not found")
|
||||
return {"ok": True, "status": new_status, "external_order_id": row[0]}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONVERT ORDER → SALE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def convert_order_to_sale(
|
||||
tenant_conn, marketplace_order_id: int, employee_id: int = None, register_id: int = None
|
||||
) -> dict:
|
||||
"""Convert a marketplace order into a Nexus sale.
|
||||
|
||||
1. Look up marketplace_order + items
|
||||
2. Map items to inventory_id (via marketplace_listings external_item_id)
|
||||
3. Build sale_data compatible with process_sale()
|
||||
4. Call process_sale()
|
||||
5. Link sale back to marketplace_order
|
||||
"""
|
||||
order = get_order_detail(tenant_conn, marketplace_order_id)
|
||||
if order.get("nexus_sale_id"):
|
||||
raise ValueError("Order already converted to sale")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
|
||||
# Build sale items
|
||||
sale_items = []
|
||||
for it in order["items"]:
|
||||
# Map external_item_id -> inventory_id via marketplace_listings
|
||||
cur.execute(
|
||||
"SELECT inventory_id FROM marketplace_listings WHERE external_item_id = %s LIMIT 1",
|
||||
(it["external_item_id"],),
|
||||
)
|
||||
inv_row = cur.fetchone()
|
||||
inventory_id = inv_row[0] if inv_row else None
|
||||
|
||||
if not inventory_id:
|
||||
# Try to match by title fuzzy? Skip for now.
|
||||
cur.close()
|
||||
raise ValueError(f"Could not map item {it['external_item_id']} to inventory")
|
||||
|
||||
sale_items.append({
|
||||
"inventory_id": inventory_id,
|
||||
"quantity": it["quantity"],
|
||||
"unit_price": float(it["unit_price"] or 0),
|
||||
"discount_pct": 0,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
# Find or create generic "MercadoLibre" customer
|
||||
cur.execute(
|
||||
"SELECT id FROM customers WHERE name = 'MercadoLibre' LIMIT 1"
|
||||
)
|
||||
cust = cur.fetchone()
|
||||
if cust:
|
||||
customer_id = cust[0]
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO customers (name, email, phone, is_active, price_tier)
|
||||
VALUES ('MercadoLibre', 'marketplace@mercadolibre.com', '', true, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
customer_id = cur.fetchone()[0]
|
||||
|
||||
# Build sale_data
|
||||
sale_data = {
|
||||
"items": sale_items,
|
||||
"customer_id": customer_id,
|
||||
"payment_method": "transferencia",
|
||||
"sale_type": "cash",
|
||||
"register_id": register_id,
|
||||
"amount_paid": float(order["total_amount"] or 0),
|
||||
"notes": f"MercadoLibre order #{order['external_order_id']}",
|
||||
}
|
||||
|
||||
# We need to run process_sale inside the same connection.
|
||||
# process_sale expects the caller to commit. We'll do that.
|
||||
# However, process_sale uses flask.g for employee_id and branch_id.
|
||||
# We need to set those or pass them explicitly.
|
||||
# For now, we'll create the sale manually to avoid flask.g dependency.
|
||||
|
||||
sale = _create_sale_manual(tenant_conn, sale_data, employee_id=employee_id)
|
||||
|
||||
# Link order to sale
|
||||
cur.execute(
|
||||
"UPDATE marketplace_orders SET nexus_sale_id = %s, status = 'confirmed', updated_at = NOW() WHERE id = %s",
|
||||
(sale["id"], marketplace_order_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"sale_id": sale["id"], "marketplace_order_id": marketplace_order_id}
|
||||
|
||||
|
||||
def _create_sale_manual(tenant_conn, sale_data: dict, employee_id: int = None) -> dict:
|
||||
"""Create a sale record without relying on flask.g.
|
||||
|
||||
Simplified version of process_sale for background / external use.
|
||||
"""
|
||||
from services.inventory_engine import record_sale as inventory_record_sale
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
items = sale_data.get("items", [])
|
||||
customer_id = sale_data.get("customer_id")
|
||||
payment_method = sale_data.get("payment_method", "efectivo")
|
||||
sale_type = sale_data.get("sale_type", "cash")
|
||||
register_id = sale_data.get("register_id")
|
||||
amount_paid = float(sale_data.get("amount_paid", 0))
|
||||
notes = sale_data.get("notes")
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Enrich items
|
||||
inv_ids = [it["inventory_id"] for it in items]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number, name, cost, price_1, tax_rate
|
||||
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id FOR UPDATE
|
||||
""",
|
||||
(inv_ids,),
|
||||
)
|
||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
enriched = []
|
||||
for it in items:
|
||||
inv_id = it["inventory_id"]
|
||||
inv = inv_map.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found")
|
||||
qty = int(it.get("quantity", 1))
|
||||
unit_price = float(it.get("unit_price", inv[4] or 0))
|
||||
discount_pct = float(it.get("discount_pct", 0))
|
||||
tax_rate = float(it.get("tax_rate", inv[5] or 0.16))
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
|
||||
enriched.append({
|
||||
"inventory_id": inv_id,
|
||||
"part_number": inv[1],
|
||||
"name": inv[2],
|
||||
"quantity": qty,
|
||||
"unit_price": unit_price,
|
||||
"unit_cost": unit_cost,
|
||||
"discount_pct": discount_pct,
|
||||
"tax_rate": tax_rate,
|
||||
})
|
||||
|
||||
totals = calculate_totals(enriched)
|
||||
|
||||
# Insert sale
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sales
|
||||
(customer_id, employee_id, register_id, sale_type, payment_method,
|
||||
subtotal, discount_total, tax_total, total, amount_paid, change_given,
|
||||
status, notes, source, external_order_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s, 'mercadolibre', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
customer_id,
|
||||
employee_id,
|
||||
register_id,
|
||||
sale_type,
|
||||
payment_method,
|
||||
totals["subtotal"],
|
||||
totals["discount_total"],
|
||||
totals["tax_total"],
|
||||
totals["total"],
|
||||
amount_paid,
|
||||
0,
|
||||
notes,
|
||||
sale_data.get("external_order_id"),
|
||||
),
|
||||
)
|
||||
sale_id = cur.fetchone()[0]
|
||||
|
||||
# Insert sale_items
|
||||
for it in totals["items"]:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sale_id,
|
||||
it["inventory_id"],
|
||||
it["part_number"],
|
||||
it["name"],
|
||||
it["quantity"],
|
||||
it["unit_price"],
|
||||
it.get("unit_cost", 0),
|
||||
it.get("discount_pct", 0),
|
||||
it.get("discount_amount", 0),
|
||||
it.get("tax_rate", 0.16),
|
||||
it.get("tax_amount", 0),
|
||||
it["subtotal"],
|
||||
),
|
||||
)
|
||||
|
||||
# Deduct inventory
|
||||
for it in enriched:
|
||||
inventory_record_sale(
|
||||
tenant_conn, it["inventory_id"], it["quantity"], reference=f"ML sale {sale_id}"
|
||||
)
|
||||
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"id": sale_id, **totals}
|
||||
@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
Expected columns (case-insensitive, whitespace-tolerant):
|
||||
part_number, stock, price
|
||||
Optional:
|
||||
min_order, warehouse_location, currency
|
||||
name, min_order, warehouse_location, currency
|
||||
|
||||
Resolution rules:
|
||||
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
|
||||
- Parts not found in the master catalog are skipped and reported.
|
||||
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
|
||||
via UPSERT; new rows are inserted.
|
||||
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
|
||||
- If matched → linked to catalog (part_id set, seller fields NULL).
|
||||
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
|
||||
- Existing rows are updated via UPSERT on the composite unique key.
|
||||
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
|
||||
"""
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
# Normalize header names
|
||||
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
cur.close()
|
||||
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
||||
|
||||
# Pre-load cross-reference map for fast lookup
|
||||
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
|
||||
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
oem_count = 0
|
||||
seller_count = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
||||
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
part_number = norm.get('part_number', '')
|
||||
stock_str = norm.get('stock', '0')
|
||||
price_str = norm.get('price', '0')
|
||||
part_name = norm.get('name', '')
|
||||
|
||||
if not part_number:
|
||||
errors.append(f'Fila {i}: part_number vacio')
|
||||
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Resolve part_number → part_id
|
||||
# Resolve part_number → part_id (OEM catalog or cross-reference)
|
||||
part_id = None
|
||||
cur.execute(
|
||||
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
||||
(part_number,)
|
||||
)
|
||||
row_part = cur.fetchone()
|
||||
if not row_part:
|
||||
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
|
||||
skipped += 1
|
||||
continue
|
||||
if row_part:
|
||||
part_id = row_part[0]
|
||||
else:
|
||||
# Try cross-reference
|
||||
xref_id = xref_map.get(part_number)
|
||||
if xref_id:
|
||||
part_id = xref_id
|
||||
|
||||
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
||||
user_id = norm.get('user_id') or bodega_id # backward compat
|
||||
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
currency = (norm.get('currency') or 'MXN').upper()
|
||||
min_order = int(norm.get('min_order') or 1)
|
||||
|
||||
# UPSERT on (user_id, part_id, warehouse_location) — the existing
|
||||
# unique constraint. Don't block if user_id FK fails.
|
||||
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
|
||||
try:
|
||||
if part_id:
|
||||
# OEM-matched listing
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, price, stock_quantity, min_order_quantity,
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (user_id, part_id, warehouse_location)
|
||||
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
bodega_id = EXCLUDED.bodega_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||
oem_count += 1
|
||||
else:
|
||||
# Seller listing (no catalog match)
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
seller_part_name = EXCLUDED.seller_part_name,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
|
||||
seller_count += 1
|
||||
|
||||
was_insert = cur.fetchone()[0]
|
||||
if was_insert:
|
||||
inserted += 1
|
||||
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
'skipped': skipped,
|
||||
'oem_count': oem_count,
|
||||
'seller_count': seller_count,
|
||||
'errors': errors[:20], # cap to avoid huge responses
|
||||
'total_errors': len(errors),
|
||||
}
|
||||
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
|
||||
Returns parts WITH stock > 0 from VERIFIED bodegas only.
|
||||
Aggregates identical parts across bodegas so the buyer sees each part once
|
||||
with a list of bodegas that have it in stock.
|
||||
|
||||
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
|
||||
(part_id IS NULL) in a single unified result set.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
|
||||
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
||||
params = []
|
||||
like = f'%{query}%' if query else None
|
||||
city_lower = city.lower() if city else None
|
||||
params_common = []
|
||||
|
||||
# Build city filter once
|
||||
city_clause = ""
|
||||
if city_lower:
|
||||
city_clause = "AND LOWER(b.city) = LOWER(%s)"
|
||||
params_common.append(city)
|
||||
|
||||
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
|
||||
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
|
||||
params_oem = []
|
||||
|
||||
if query:
|
||||
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
like = f'%{query}%'
|
||||
params.extend([like, like, like])
|
||||
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
params_oem.extend([like, like, like])
|
||||
|
||||
if brand:
|
||||
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
|
||||
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
|
||||
clauses.append("""
|
||||
clauses_oem.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
|
||||
)
|
||||
""")
|
||||
params.append(brand)
|
||||
params_oem.append(brand)
|
||||
|
||||
if city:
|
||||
clauses.append("LOWER(b.city) = LOWER(%s)")
|
||||
params.append(city)
|
||||
where_oem = " AND ".join(clauses_oem)
|
||||
|
||||
where_sql = " AND ".join(clauses)
|
||||
# ─── Part B: Seller listings (no parts catalog join) ──────────────
|
||||
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
|
||||
params_seller = []
|
||||
|
||||
cur.execute(f"""
|
||||
if query:
|
||||
clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
|
||||
params_seller.extend([like, like])
|
||||
|
||||
where_seller = " AND ".join(clauses_seller)
|
||||
|
||||
# Combined query with UNION ALL
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
-- OEM-matched parts
|
||||
SELECT
|
||||
p.id_part,
|
||||
p.oem_part_number,
|
||||
p.id_part AS id,
|
||||
p.oem_part_number AS part_number,
|
||||
COALESCE(p.name_es, p.name_part) AS name,
|
||||
p.image_url,
|
||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||
MIN(wi.price) AS min_price,
|
||||
MAX(wi.price) AS max_price,
|
||||
SUM(wi.stock_quantity) AS total_stock,
|
||||
-- List of bodega names that have this part in stock
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'oem' AS listing_type
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
JOIN parts p ON p.id_part = wi.part_id
|
||||
WHERE {where_sql}
|
||||
WHERE {where_oem} {city_clause}
|
||||
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Seller listings
|
||||
SELECT
|
||||
wi.id_inventory AS id,
|
||||
wi.seller_part_number AS part_number,
|
||||
wi.seller_part_name AS name,
|
||||
NULL AS image_url,
|
||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||
MIN(wi.price) AS min_price,
|
||||
MAX(wi.price) AS max_price,
|
||||
SUM(wi.stock_quantity) AS total_stock,
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'seller' AS listing_type
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
WHERE {where_seller} {city_clause}
|
||||
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
|
||||
) combined
|
||||
ORDER BY total_stock DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
"""
|
||||
|
||||
all_params = params_oem + params_common + params_seller + params_common + [limit]
|
||||
cur.execute(sql, all_params)
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return [
|
||||
{
|
||||
'id_part': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'id': r[0],
|
||||
'part_number': r[1],
|
||||
'name': r[2],
|
||||
'image_url': r[3],
|
||||
'bodega_count': r[4],
|
||||
'min_price': float(r[5]) if r[5] is not None else None,
|
||||
'max_price': float(r[6]) if r[6] is not None else None,
|
||||
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
|
||||
'bodega_names': r[8], # may expose; adjust if sensitive
|
||||
'bodega_names': r[8],
|
||||
'listing_type': r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
|
||||
"""Return the list of verified bodegas that have a specific seller listing
|
||||
(warehouse_inventory row with part_id IS NULL) in stock.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
|
||||
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
WHERE wi.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
|
||||
ORDER BY wi.price ASC
|
||||
""", (wi_id,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [
|
||||
{
|
||||
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
|
||||
'price': float(r[4]) if r[4] is not None else None,
|
||||
'stock_hint': 'En stock',
|
||||
'min_order': r[6] or 1,
|
||||
'currency': r[7] or 'MXN',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -397,12 +504,15 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
# Insert items
|
||||
total = 0.0
|
||||
for item in items:
|
||||
part_id = int(item['part_id'])
|
||||
part_id = item.get('part_id')
|
||||
wi_id = item.get('wi_id')
|
||||
quantity = int(item['quantity'])
|
||||
if quantity < 1:
|
||||
continue
|
||||
|
||||
# Lookup part info + price
|
||||
if part_id:
|
||||
# OEM-matched part
|
||||
part_id = int(part_id)
|
||||
cur.execute("""
|
||||
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||
FROM parts p
|
||||
@@ -420,10 +530,35 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
|
||||
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
elif wi_id:
|
||||
# Seller listing (no catalog match)
|
||||
wi_id = int(wi_id)
|
||||
cur.execute("""
|
||||
SELECT seller_part_number, seller_part_name, price
|
||||
FROM warehouse_inventory
|
||||
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
|
||||
""", (wi_id, bodega_id))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
continue
|
||||
seller_pn, seller_name, db_price = r
|
||||
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||
subtotal = round(unit_price * quantity, 2)
|
||||
total += subtotal
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
|
||||
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
# Update header total
|
||||
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||
(round(total, 2), po_id))
|
||||
|
||||
233
pos/services/meli_service.py
Normal file
233
pos/services/meli_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""MercadoLibre API client with OAuth2 auto-refresh.
|
||||
|
||||
Endpoints used:
|
||||
- GET /users/me
|
||||
- POST /items
|
||||
- PUT /items/{id}
|
||||
- GET /items/{id}
|
||||
- GET /orders/search
|
||||
- GET /orders/{id}
|
||||
- POST /shipments/{id}/dispatch
|
||||
- POST /oauth/token
|
||||
|
||||
References:
|
||||
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
|
||||
"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
BASE_URL = "https://api.mercadolibre.com"
|
||||
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
|
||||
|
||||
|
||||
class MeliError(Exception):
|
||||
def __init__(self, message, status_code=None, response_body=None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
class MeliAuthError(MeliError):
|
||||
pass
|
||||
|
||||
|
||||
class MeliService:
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||||
|
||||
# ─── Low-level request ───────────────────────────────────────────────
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[dict] = None,
|
||||
json_payload: Optional[dict] = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> dict:
|
||||
url = f"{BASE_URL}{path}"
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
|
||||
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
|
||||
self._refresh_token()
|
||||
# Retry once with new token
|
||||
self._session.headers.update(
|
||||
{"Authorization": f"Bearer {self.access_token}"}
|
||||
)
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
raise MeliAuthError(
|
||||
"Unauthorized. Token may be expired or invalid.",
|
||||
status_code=401,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
raise MeliError(
|
||||
f"Meli API error {resp.status_code}: {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
# Some endpoints return 204 No Content
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"raw": resp.text}
|
||||
|
||||
def _refresh_token(self) -> dict:
|
||||
if not self.client_id or not self.client_secret or not self.refresh_token:
|
||||
raise MeliAuthError("Missing credentials for token refresh")
|
||||
payload = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
}
|
||||
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||
if not resp.ok:
|
||||
raise MeliAuthError(
|
||||
f"Token refresh failed: {resp.status_code} {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
data = resp.json()
|
||||
self.access_token = data["access_token"]
|
||||
if "refresh_token" in data:
|
||||
self.refresh_token = data["refresh_token"]
|
||||
return data
|
||||
|
||||
# ─── Auth / User ─────────────────────────────────────────────────────
|
||||
|
||||
def get_user(self) -> dict:
|
||||
return self._request("GET", "/users/me")
|
||||
|
||||
@staticmethod
|
||||
def exchange_code(
|
||||
code: str, client_id: str, client_secret: str, redirect_uri: str
|
||||
) -> dict:
|
||||
"""Exchange authorization code for tokens."""
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||
if not resp.ok:
|
||||
raise MeliAuthError(
|
||||
f"Code exchange failed: {resp.status_code} {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
# ─── Items (listings) ────────────────────────────────────────────────
|
||||
|
||||
def create_item(self, payload: dict) -> dict:
|
||||
return self._request("POST", "/items", json_payload=payload)
|
||||
|
||||
def update_item(self, item_id: str, payload: dict) -> dict:
|
||||
return self._request("PUT", f"/items/{item_id}", json_payload=payload)
|
||||
|
||||
def get_item(self, item_id: str) -> dict:
|
||||
return self._request("GET", f"/items/{item_id}")
|
||||
|
||||
def pause_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "paused"})
|
||||
|
||||
def activate_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "active"})
|
||||
|
||||
def close_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "closed"})
|
||||
|
||||
# ─── Categories ──────────────────────────────────────────────────────
|
||||
|
||||
def get_category(self, category_id: str) -> dict:
|
||||
return self._request("GET", f"/categories/{category_id}")
|
||||
|
||||
def search_categories(self, site_id: str, query: str) -> dict:
|
||||
# ML does not have a direct category search; we use the predictor
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/sites/{site_id}/domain_discovery/search",
|
||||
params={"q": query},
|
||||
)
|
||||
|
||||
def get_category_attributes(self, category_id: str) -> list:
|
||||
return self._request("GET", f"/categories/{category_id}/attributes")
|
||||
|
||||
# ─── Orders ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_orders(
|
||||
self,
|
||||
seller_id: str,
|
||||
status: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
params = {"seller": seller_id, "limit": limit, "offset": offset}
|
||||
if status:
|
||||
params["order.status"] = status
|
||||
if date_from:
|
||||
params["order.date_created.from"] = date_from
|
||||
return self._request("GET", "/orders/search", params=params)
|
||||
|
||||
def get_order(self, order_id: str) -> dict:
|
||||
return self._request("GET", f"/orders/{order_id}")
|
||||
|
||||
# ─── Shipments ───────────────────────────────────────────────────────
|
||||
|
||||
def get_shipment(self, shipment_id: str) -> dict:
|
||||
return self._request("GET", f"/shipments/{shipment_id}")
|
||||
|
||||
def mark_ready_to_ship(self, shipment_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/shipments/{shipment_id}/dispatch",
|
||||
json_payload={},
|
||||
)
|
||||
|
||||
# ─── Notifications / Webhooks validation ─────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def validate_webhook_signature(
|
||||
secret: str, data: bytes, signature_header: str
|
||||
) -> bool:
|
||||
"""Validate MercadoLibre webhook signature.
|
||||
|
||||
ML sends: X-Signature: sha256=<hex_hmac>
|
||||
"""
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
if not signature_header or "=" not in signature_header:
|
||||
return False
|
||||
_, expected_hex = signature_header.split("=", 1)
|
||||
computed = hmac.new(
|
||||
secret.encode(), data, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(computed, expected_hex)
|
||||
186
pos/services/part_kits.py
Normal file
186
pos/services/part_kits.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Smart part kits — automatic cross-sell recommendations.
|
||||
|
||||
When a customer adds a part to their quotation, suggest related
|
||||
parts that are typically needed together for a complete job.
|
||||
"""
|
||||
|
||||
# Spanish keywords in part name → related parts to suggest (in Spanish)
|
||||
# These appear after a successful "cotizar" command.
|
||||
KIT_SUGGESTIONS = {
|
||||
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
|
||||
"disco de freno": ["balata", "líquido de frenos"],
|
||||
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
|
||||
"batería": ["alternador", "cable de bujía"],
|
||||
"marcha": ["batería", "solenoide de marcha"],
|
||||
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
|
||||
"bobina": ["bujía", "cable de bujía"],
|
||||
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
|
||||
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
|
||||
"termostato": ["refrigerante", "manguera de radiador"],
|
||||
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
|
||||
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
|
||||
"terminal": ["rótula", "brazo de suspensión"],
|
||||
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
|
||||
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
|
||||
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
|
||||
"clutch": ["collarín", "disco de clutch", "plato de presión"],
|
||||
"collarín": ["clutch", "disco de clutch"],
|
||||
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
|
||||
"banda serpentina": ["tensor de banda", "polea loca"],
|
||||
"foco": ["foco trasero", "cuarto"],
|
||||
"faro": ["foco trasero", "cuarto"],
|
||||
"aceite": ["filtro de aceite", "filtro de aire"],
|
||||
}
|
||||
|
||||
|
||||
def get_kit_suggestions(part_name: str) -> list:
|
||||
"""Return related part names for a given part (Spanish)."""
|
||||
if not part_name:
|
||||
return []
|
||||
name_lower = part_name.lower()
|
||||
for keyword, related in KIT_SUGGESTIONS.items():
|
||||
if keyword in name_lower:
|
||||
return related
|
||||
return []
|
||||
|
||||
|
||||
def build_kit_text(part_name: str) -> str:
|
||||
"""Build a WhatsApp-friendly kit suggestion text.
|
||||
|
||||
Returns empty string if no kit is found.
|
||||
"""
|
||||
suggestions = get_kit_suggestions(part_name)
|
||||
if not suggestions:
|
||||
return ""
|
||||
items = "\n".join(f" • {s.title()}" for s in suggestions[:3])
|
||||
return (
|
||||
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
|
||||
+ items
|
||||
+ '\n\n_Escribe la parte que te interese y la agregamos._'
|
||||
)
|
||||
|
||||
|
||||
# ── Urgency detection ────────────────────────────────────────────────
|
||||
URGENCY_KEYWORDS = [
|
||||
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
|
||||
"lo necesito", "se me paro", "no arranca", "no jala",
|
||||
"rapido", "apúrate", "apurate", "prisa", "de volada",
|
||||
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
|
||||
"inmediato", "express", "exprés",
|
||||
]
|
||||
|
||||
|
||||
def is_urgent(text: str) -> bool:
|
||||
"""Detect if the customer message signals urgency."""
|
||||
if not text:
|
||||
return False
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in URGENCY_KEYWORDS)
|
||||
|
||||
|
||||
def urgency_note() -> str:
|
||||
return (
|
||||
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
|
||||
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
|
||||
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
|
||||
)
|
||||
|
||||
|
||||
# ── Abandoned quotation follow-up ────────────────────────────────────
|
||||
FOLLOW_UP_MINUTES = 15
|
||||
|
||||
|
||||
def should_send_followup(phone: str, tenant_conn) -> str:
|
||||
"""Check if we should send a follow-up message for an abandoned quotation.
|
||||
|
||||
Returns the follow-up text if yes, empty string if no.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return ""
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
# 1. Check if there's an active quotation for this phone
|
||||
cur.execute("""
|
||||
SELECT id FROM quotations
|
||||
WHERE notes LIKE %s AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (f'%WA:{phone}%',))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return ""
|
||||
|
||||
# 2. Check last bot message mentioning "cotización" or "cotizar"
|
||||
cur.execute("""
|
||||
SELECT created_at, message_text
|
||||
FROM whatsapp_messages
|
||||
WHERE phone = %s AND direction = 'outgoing'
|
||||
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (phone,))
|
||||
last_quote_msg = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not last_quote_msg:
|
||||
return ""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
last_time = last_quote_msg[0]
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_time.tzinfo is None:
|
||||
last_time = last_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
minutes_since = (now - last_time).total_seconds() / 60
|
||||
if minutes_since >= FOLLOW_UP_MINUTES:
|
||||
return (
|
||||
"👋 *¿Todo bien?*\n\n"
|
||||
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
|
||||
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Follow-up check failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ── Customer purchase history awareness ──────────────────────────────
|
||||
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
|
||||
"""Build a short text summary of recent confirmed quotations for this customer.
|
||||
|
||||
Returns empty string if no history.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return ""
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT q.id, q.created_at, q.total,
|
||||
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
|
||||
FROM quotations q
|
||||
JOIN quotation_items qi ON qi.quotation_id = q.id
|
||||
WHERE q.notes LIKE %s AND q.status = 'converted'
|
||||
GROUP BY q.id, q.created_at, q.total
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s
|
||||
""", (f'%WA:{phone}%', limit))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
parts = []
|
||||
for qid, created, total, items in rows:
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
months_ago = (now - created).days // 30
|
||||
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
|
||||
item_list = ", ".join(items[:3])
|
||||
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
|
||||
|
||||
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Purchase history failed: {e}")
|
||||
return ""
|
||||
127
pos/services/quote_image.py
Normal file
127
pos/services/quote_image.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import base64
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
|
||||
"""
|
||||
Generate a visually appealing quote image.
|
||||
|
||||
quote_items: list of dicts with keys: name, sku, qty, price, total
|
||||
totals: dict with keys: subtotal, tax, total
|
||||
Returns: base64 encoded PNG string
|
||||
"""
|
||||
# Dimensions
|
||||
WIDTH = 800
|
||||
HEADER_H = 120
|
||||
FOOTER_H = 100
|
||||
ITEM_H = 60
|
||||
PADDING = 30
|
||||
|
||||
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
|
||||
|
||||
# Colors
|
||||
BG_COLOR = (250, 250, 252)
|
||||
PRIMARY = (0, 82, 155) # Dark blue
|
||||
ACCENT = (230, 57, 70) # Red accent
|
||||
TEXT_DARK = (30, 30, 30)
|
||||
TEXT_MED = (80, 80, 80)
|
||||
TEXT_LIGHT = (150, 150, 150)
|
||||
WHITE = (255, 255, 255)
|
||||
ROW_ALT = (245, 247, 250)
|
||||
|
||||
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load fonts, fallback to default
|
||||
try:
|
||||
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
||||
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
|
||||
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||
except Exception:
|
||||
font_title = ImageFont.load_default()
|
||||
font_sub = font_title
|
||||
font_item = font_title
|
||||
font_bold = font_title
|
||||
font_small = font_title
|
||||
|
||||
# --- Header ---
|
||||
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
|
||||
|
||||
# Logo text
|
||||
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
|
||||
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# Date and Quote label
|
||||
from datetime import datetime
|
||||
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
|
||||
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# --- Items Header ---
|
||||
y = HEADER_H + PADDING
|
||||
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
|
||||
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
|
||||
y += ITEM_H
|
||||
|
||||
# --- Items ---
|
||||
for idx, item in enumerate(quote_items):
|
||||
row_y = y + idx * ITEM_H
|
||||
bg = ROW_ALT if idx % 2 == 0 else WHITE
|
||||
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
|
||||
|
||||
name = item.get('name', 'Producto')
|
||||
sku = item.get('sku', '')
|
||||
qty = str(item.get('qty', 1))
|
||||
price = f"${item.get('price', 0):,.2f}"
|
||||
total = f"${item.get('total', 0):,.2f}"
|
||||
|
||||
# Truncate name if too long
|
||||
name_display = name
|
||||
if len(name_display) > 35:
|
||||
name_display = name_display[:32] + "..."
|
||||
|
||||
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
|
||||
|
||||
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
|
||||
|
||||
y += len(quote_items) * ITEM_H + PADDING
|
||||
|
||||
# --- Totals ---
|
||||
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
|
||||
y += 20
|
||||
|
||||
subtotal = totals.get('subtotal', 0)
|
||||
tax = totals.get('tax', 0)
|
||||
total = totals.get('total', 0)
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 30
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 35
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
|
||||
y += 50
|
||||
|
||||
# --- Footer ---
|
||||
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
|
||||
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
|
||||
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.read()).decode('utf-8')
|
||||
|
||||
@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
|
||||
cur.close()
|
||||
|
||||
|
||||
def set_last_shown_part(phone, part_info):
|
||||
def set_last_shown_part(tenant_conn, phone, part_info):
|
||||
"""Store the last part shown to this phone number.
|
||||
|
||||
part_info: dict with keys inventory_id, part_number, name, brand,
|
||||
price, stock, unit
|
||||
"""
|
||||
# In-memory fallback for when tenant_conn is not available
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
||||
""", (phone, json.dumps(part_info)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
||||
|
||||
|
||||
def get_last_shown_part(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def get_last_shown_part(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_last_shown(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def clear_last_shown(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
|
||||
|
||||
|
||||
def set_vehicle(phone, vehicle):
|
||||
def set_vehicle(tenant_conn, phone, vehicle):
|
||||
"""Store the detected vehicle for this phone number.
|
||||
|
||||
vehicle: dict with keys brand, model, year
|
||||
"""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
||||
""", (phone, json.dumps(vehicle)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
|
||||
|
||||
|
||||
def get_vehicle(phone):
|
||||
def get_vehicle(tenant_conn, phone):
|
||||
"""Retrieve the stored vehicle for this phone number."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -232,17 +216,14 @@ def get_vehicle(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_session(phone):
|
||||
def clear_session(tenant_conn, phone):
|
||||
"""Clear all session data (last_shown + vehicle) for this phone."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ def process_incoming(webhook_data):
|
||||
# - For 'text' messages → conversation or extendedTextMessage
|
||||
# - For 'image'/'video' → the caption (may be empty)
|
||||
# - For 'audio' → empty (filled in later by Whisper transcription)
|
||||
# - For 'location' → synthetic text with coordinates
|
||||
if media_kind == 'text':
|
||||
text = (
|
||||
message.get('conversation', '')
|
||||
@@ -114,6 +115,10 @@ def process_incoming(webhook_data):
|
||||
else:
|
||||
text = media_caption
|
||||
|
||||
# Location fields (from bridge classification)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
return {
|
||||
'phone': phone,
|
||||
'jid': remote_jid,
|
||||
@@ -125,4 +130,20 @@ def process_incoming(webhook_data):
|
||||
'media_mimetype': media_mimetype,
|
||||
'is_voice_note': is_voice_note,
|
||||
'push_name': push_name,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
}
|
||||
|
||||
|
||||
def send_image(phone, caption, base64_image, bridge_url=None):
|
||||
"""Send an image message via the Baileys bridge."""
|
||||
url = _get_url(bridge_url)
|
||||
try:
|
||||
return requests.post(
|
||||
f'{url}/send-image',
|
||||
headers=HEADERS,
|
||||
json={'phone': phone, 'caption': caption, 'base64': base64_image},
|
||||
timeout=15
|
||||
).json()
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
@@ -732,6 +732,20 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.btn--meli {
|
||||
background: #FFE600;
|
||||
color: #2D3277;
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn--meli:hover {
|
||||
background: #e6cf00;
|
||||
color: #1a1f5c;
|
||||
}
|
||||
.btn--meli svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
DATA TABLE
|
||||
========================================================================= */
|
||||
@@ -1261,7 +1275,7 @@
|
||||
.inv-field label {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1282,6 +1296,23 @@
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.inv-field select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inv-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.count-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -1301,3 +1332,48 @@
|
||||
|
||||
/* History table inside modal */
|
||||
.inv-modal .data-table { width: 100%; }
|
||||
|
||||
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
|
||||
.meli-cat-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.meli-cat-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.meli-cat-item:last-child { border-bottom: none; }
|
||||
.meli-cat-item:hover,
|
||||
.meli-cat-item.is-active {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.meli-cat-item .cat-id {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.meli-cat-loading,
|
||||
.meli-cat-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -18,14 +18,22 @@ body {
|
||||
SIDEBAR — Glass treatment
|
||||
========================================================================== */
|
||||
|
||||
/* Prevent flash/stun while sidebar.js replaces static sidebar markup */
|
||||
.sidebar,
|
||||
.pos-sidebar {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
background: var(--glass-bg-strong) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--glass-border) !important;
|
||||
}
|
||||
|
||||
body.sidebar-ready .sidebar,
|
||||
body.sidebar-ready .pos-sidebar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
1
pos/static/js/app-init.min.js
vendored
1
pos/static/js/app-init.min.js
vendored
@@ -1 +0,0 @@
|
||||
!function(){"use strict";var e=localStorage.getItem("pos_token");if(e){try{var t=JSON.parse(atob(e.split(".")[1]));if(1e3*t.exp<Date.now())return localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),void(window.location.href="/pos/login")}catch(e){return localStorage.removeItem("pos_token"),void(window.location.href="/pos/login")}var o={};try{o=JSON.parse(localStorage.getItem("pos_employee")||"{}")}catch(e){}var n=o.name||t.name||"Usuario",a=o.role||t.role||"",r="function"==typeof window.t?window.t:function(e){return e},i={owner:r("role_owner"),admin:r("role_admin"),cashier:r("role_cashier"),warehouse:r("role_warehouse"),accountant:r("role_accountant")}[a]||a,c=n.split(" ").map((function(e){return e[0]})).join("").toUpperCase().substring(0,2);document.querySelectorAll(".sidebar__user-name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".sidebar__user-role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".sidebar__user-avatar, .sidebar__avatar").forEach((function(e){e.textContent=c})),document.querySelectorAll(".profile-info__name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".profile-info__role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".theme-bar__label").forEach((function(e){-1===e.textContent.indexOf("Usuario:")&&-1===e.textContent.indexOf("Sucursal")||(e.textContent="Sucursal Principal — "+n)})),document.querySelectorAll(".status-bar .user-name, .status-info span").forEach((function(e){var t=e.textContent;["Hugo M.","Hugo García","J. Ramírez","José Ramírez","Carlos M.","Admin"].forEach((function(o){-1!==t.indexOf(o)&&(e.textContent=t.replace(o,n))}))}));var l=window.location.pathname;document.querySelectorAll(".nav-item, .nav-link").forEach((function(e){e.classList.remove("is-active","active"),(e.getAttribute("href")||"")===l&&(e.classList.add("is-active"),e.classList.add("active"))})),window.posLogout=function(){localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),localStorage.removeItem("pos_tenant_id"),localStorage.removeItem("pos_cart"),window.location.href="/pos/login"},document.querySelectorAll('[data-action="logout"], .btn-logout, .logout-btn').forEach((function(e){e.addEventListener("click",(function(e){e.preventDefault(),posLogout()}))}));var s=localStorage.getItem("pos_theme");s||(s=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"industrial":"modern"),document.documentElement.setAttribute("data-theme",s),document.querySelectorAll(".theme-bar").forEach((function(e){e.style.display="none"})),window.posSetTheme=function(e){document.documentElement.setAttribute("data-theme",e),localStorage.setItem("pos_theme",e)},window.setTheme=window.posSetTheme,window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",(function(e){if(!localStorage.getItem("pos_theme")){var t=e.matches?"industrial":"modern";document.documentElement.setAttribute("data-theme",t)}})),setTimeout((function(){document.documentElement.setAttribute("data-theme",s)}),100),window.POS_USER={name:n,role:a,roleLabel:i,initials:c,token:e,tenantId:t.tenant_id,employeeId:t.employee_id,branchId:t.branch_id,permissions:t.permissions||[]}}else window.location.href="/pos/login"}();
|
||||
@@ -6,6 +6,7 @@
|
||||
_offset: 0,
|
||||
_limit: 50,
|
||||
_total: 0,
|
||||
_allowedBrands: [],
|
||||
|
||||
// Navigation state
|
||||
nav: {
|
||||
@@ -71,7 +72,9 @@
|
||||
},
|
||||
|
||||
loading: function(on) {
|
||||
this.el('brandCatalogLoading').style.display = on ? 'block' : 'none';
|
||||
var el = this.el('brandCatalogLoading');
|
||||
if (on) el.classList.add('is-visible');
|
||||
else el.classList.remove('is-visible');
|
||||
},
|
||||
|
||||
setContent: function(html) {
|
||||
@@ -88,23 +91,23 @@
|
||||
|
||||
buildBreadcrumb: function() {
|
||||
var parts = [];
|
||||
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a>');
|
||||
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick="BrandCatalog.loadBrands()">Marcas</a>');
|
||||
if (this.nav.brand) {
|
||||
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.brand) + '</a>');
|
||||
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>');
|
||||
}
|
||||
if (this.nav.model) {
|
||||
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.model) + '</a>');
|
||||
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
|
||||
}
|
||||
if (this.nav.year) {
|
||||
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')" style="color:var(--color-primary);text-decoration:none;">' + this.nav.year + '</a>');
|
||||
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>');
|
||||
}
|
||||
if (this.nav.engine) {
|
||||
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.engine) + '</a>');
|
||||
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')\'>' + escapeHtml(this.nav.engine) + '</a>');
|
||||
}
|
||||
if (this.nav.category) {
|
||||
parts.push('<strong>' + escapeHtml(this.nav.category) + '</strong>');
|
||||
parts.push('<span class="breadcrumb__current">' + escapeHtml(this.nav.category) + '</span>');
|
||||
}
|
||||
this.setBreadcrumb(parts.join(' › '));
|
||||
this.setBreadcrumb('<nav class="breadcrumb">' + parts.join('<span class="breadcrumb__sep">›</span>') + '</nav>');
|
||||
},
|
||||
|
||||
// ---------- BRANDS ----------
|
||||
@@ -112,12 +115,10 @@
|
||||
this.loading(true);
|
||||
this.state = 'brands';
|
||||
this.reset();
|
||||
this.setBreadcrumb('<strong>Marcas de vehiculo</strong>');
|
||||
this.setBreadcrumb('<nav class="breadcrumb"><span class="breadcrumb__current">Marcas de vehiculo</span></nav>');
|
||||
this.setSearch(
|
||||
'<input type="text" id="brandSearchInput" placeholder="Buscar marca..." ' +
|
||||
'style="width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
|
||||
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);' +
|
||||
'outline:none;" oninput="BrandCatalog.filterBrands(this.value)">'
|
||||
'class="level-filter" oninput="BrandCatalog.filterBrands(this.value)">'
|
||||
);
|
||||
var self = this;
|
||||
fetch('/pos/api/catalog/vehicle-brands', { headers: this._headers() })
|
||||
@@ -130,24 +131,25 @@
|
||||
self.loading(false);
|
||||
self._allBrands = data.brands || [];
|
||||
if (!self._allBrands.length) {
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron marcas.</div></div>');
|
||||
return;
|
||||
}
|
||||
self.renderBrandList(self._allBrands);
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar marcas: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar marcas</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderBrandList: function(brands) {
|
||||
var html = '';
|
||||
var html = '<div class="nav-grid">';
|
||||
brands.forEach(function(b) {
|
||||
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')">' +
|
||||
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(b.name) + '</div>' +
|
||||
html += '<div class="nav-card" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')\'>' +
|
||||
'<div class="nav-card__name">' + escapeHtml(b.name) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
@@ -186,23 +188,28 @@
|
||||
self.loading(false);
|
||||
var models = data.data || [];
|
||||
if (!models.length) {
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron modelos.</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron modelos.</div></div>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
models.forEach(function(m) {
|
||||
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')">' +
|
||||
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
self.setContent(html);
|
||||
self.renderModelList(models);
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar modelos: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar modelos</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderModelList: function(models) {
|
||||
var html = '<div class="nav-grid">';
|
||||
models.forEach(function(m) {
|
||||
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')\'>' +
|
||||
'<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
selectModel: function(modelId, modelName) {
|
||||
this.nav.model = modelName;
|
||||
this.nav.modelId = modelId;
|
||||
@@ -226,23 +233,28 @@
|
||||
self.loading(false);
|
||||
var years = data.data || [];
|
||||
if (!years.length) {
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron años.</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron años.</div></div>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
years.forEach(function(y) {
|
||||
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')">' +
|
||||
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + y.year_car + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
self.setContent(html);
|
||||
self.renderYearList(years);
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar años: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar años</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderYearList: function(years) {
|
||||
var html = '<div class="nav-grid nav-grid--years">';
|
||||
years.forEach(function(y) {
|
||||
html += '<div class="nav-card nav-card--year" onclick=\'BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')\'>' +
|
||||
'<div class="nav-card__name">' + y.year_car + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
selectYear: function(yearId, yearCar) {
|
||||
this.nav.year = yearCar;
|
||||
this.nav.yearId = yearId;
|
||||
@@ -266,24 +278,29 @@
|
||||
self.loading(false);
|
||||
var engines = data.data || [];
|
||||
if (!engines.length) {
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron motores.</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron motores.</div></div>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
engines.forEach(function(e) {
|
||||
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')">' +
|
||||
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(e.name_engine) + '</div>' +
|
||||
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + escapeHtml(e.trim_level || '') + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
self.setContent(html);
|
||||
self.renderEngineList(engines);
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar motores: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar motores</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderEngineList: function(engines) {
|
||||
var html = '<div class="nav-grid">';
|
||||
engines.forEach(function(e) {
|
||||
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')\'>' +
|
||||
'<div class="nav-card__name">' + escapeHtml(e.name_engine) + '</div>' +
|
||||
'<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
selectEngine: function(myeId, engineName) {
|
||||
this.nav.engine = engineName;
|
||||
this.nav.myeId = myeId;
|
||||
@@ -305,26 +322,36 @@
|
||||
.then(function(data) {
|
||||
if (!data) return;
|
||||
self.loading(false);
|
||||
self._allowedBrands = data.allowed_brands || [];
|
||||
var categories = data.data || [];
|
||||
if (!categories.length) {
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron categorias.</p>');
|
||||
var msg = 'No se encontraron categorias.';
|
||||
if (self._allowedBrands.length) {
|
||||
msg = 'Este vehiculo no tiene cobertura de ' + self._allowedBrands.join(', ') + '.';
|
||||
}
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">' + msg + '</div><div class="empty-state__subtitle">Prueba con otro vehiculo o contacta a soporte para ampliar el catalogo.</div></div>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
categories.forEach(function(c) {
|
||||
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')">' +
|
||||
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(c.name) + '</div>' +
|
||||
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (c.part_count || 0) + ' refacciones</div>' +
|
||||
'</div>';
|
||||
});
|
||||
self.setContent(html);
|
||||
self.renderCategoryList(categories);
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar categorias: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar categorias</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderCategoryList: function(categories) {
|
||||
var html = '<div class="nav-grid">';
|
||||
categories.forEach(function(c) {
|
||||
html += '<div class="nav-card" onclick=\'BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')\'>' +
|
||||
'<div class="nav-card__name">' + escapeHtml(c.name) + '</div>' +
|
||||
'<div class="nav-card__sub">' + (c.part_count || 0) + ' refacciones</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
selectCategory: function(catId, catName) {
|
||||
this.nav.category = catName;
|
||||
this.nav.categoryId = catId;
|
||||
@@ -340,11 +367,10 @@
|
||||
this.setSearch(
|
||||
'<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;align-items:center;">' +
|
||||
'<input type="text" id="partsSearchInput" placeholder="Buscar refaccion..." value="' + escapeHtml(searchTerm || '') + '" ' +
|
||||
'style="flex:1;min-width:200px;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
|
||||
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);outline:none;" ' +
|
||||
'class="level-filter" ' +
|
||||
'onkeydown="if(event.key===\'Enter\')BrandCatalog.searchParts(this.value)">' +
|
||||
'<button class="btn btn--primary btn--sm" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
|
||||
'<button class="btn btn--secondary btn--sm" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
|
||||
'<button class="btn btn-primary" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
|
||||
'<button class="btn btn-ghost" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
|
||||
'</div>'
|
||||
);
|
||||
var url = '/pos/api/catalog/mye-parts?mye_id=' + encodeURIComponent(myeId) + '&category_id=' + encodeURIComponent(categoryId) +
|
||||
@@ -361,6 +387,7 @@
|
||||
.then(function(data) {
|
||||
if (!data) return;
|
||||
self.loading(false);
|
||||
self._allowedBrands = data.allowed_brands || [];
|
||||
self._lastItems = data.items || [];
|
||||
self._total = data.total || 0;
|
||||
self._offset = data.offset || 0;
|
||||
@@ -368,16 +395,20 @@
|
||||
})
|
||||
.catch(function(err) {
|
||||
self.loading(false);
|
||||
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar refacciones: ' + escapeHtml(err.message) + '</p>');
|
||||
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar refacciones</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
|
||||
});
|
||||
},
|
||||
|
||||
renderPartsList: function(items, searchTerm) {
|
||||
var html = '';
|
||||
if (!items.length) {
|
||||
html += '<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
|
||||
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron refacciones.</p>' +
|
||||
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button>' +
|
||||
var msg = 'No se encontraron refacciones.';
|
||||
if (this._allowedBrands.length) {
|
||||
msg = 'No hay refacciones de ' + this._allowedBrands.join(', ') + ' en esta categoria.';
|
||||
}
|
||||
html += '<div class="empty-state is-visible">' +
|
||||
'<div class="empty-state__title">' + msg + '</div>' +
|
||||
'<div class="empty-state__subtitle"><button class="btn btn-primary" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button></div>' +
|
||||
'</div>';
|
||||
this.setContent(html);
|
||||
return;
|
||||
@@ -389,40 +420,55 @@
|
||||
'Mostrando ' + startIdx + '-' + endIdx + ' de ' + this._total + ' refacciones' +
|
||||
'</div>';
|
||||
|
||||
html += '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
|
||||
html += '<div class="nav-grid nav-grid--parts">';
|
||||
items.forEach(function(p) {
|
||||
var price = p.local_price ? '$' + Number(p.local_price).toFixed(2) : 'Consultar precio';
|
||||
var img = '/pos/static/images/placeholder-part.png';
|
||||
var hasAm = !!p.manufacturer;
|
||||
var price = p.local_price
|
||||
? '$' + Number(p.local_price).toFixed(2)
|
||||
: (p.price_usd ? '$' + Number(p.price_usd).toFixed(2) : 'Consultar precio');
|
||||
var stockBadge = p.local_stock > 0
|
||||
? '<span style="display:inline-block;background:var(--color-success);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">' + p.local_stock + ' en stock</span>'
|
||||
: '<span style="display:inline-block;background:var(--color-text-muted);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">Sin stock local</span>';
|
||||
html += '<div class="catalog-category-card" style="padding:0;overflow:hidden;display:flex;flex-direction:column;">' +
|
||||
'<div style="height:160px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;">' +
|
||||
'<img src="' + escapeHtml(img) + '" alt="" style="max-width:100%;max-height:100%;object-fit:contain;">' +
|
||||
? '<span class="stock-badge stock-badge--local">En stock</span>'
|
||||
: '<span class="stock-badge stock-badge--none">Sin stock local</span>';
|
||||
var imgHtml = p.image_url
|
||||
? '<img src="' + escapeHtml(p.image_url) + '" alt="">'
|
||||
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
|
||||
var brandLine = hasAm
|
||||
? '<div style="font-size:var(--text-caption);color:var(--color-accent);font-weight:600;">' + escapeHtml(p.manufacturer) + '</div>'
|
||||
: '';
|
||||
html += '<div class="part-card">' +
|
||||
'<div class="part-card__image">' + imgHtml + '</div>' +
|
||||
'<div class="part-card__body">' +
|
||||
brandLine +
|
||||
'<div class="part-card__oem">' + escapeHtml(p.oem_part_number || 'N/A') + '</div>' +
|
||||
'<div class="part-card__name">' + escapeHtml(p.name || '') + '</div>' +
|
||||
'</div>' +
|
||||
'<div style="padding:var(--space-3);flex:1;display:flex;flex-direction:column;">' +
|
||||
'<div style="font-weight:600;font-size:var(--text-body);margin-bottom:4px;">' + escapeHtml(p.oem_part_number || 'N/A') + stockBadge + '</div>' +
|
||||
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:8px;flex:1;">' + escapeHtml(p.name || '') + '</div>' +
|
||||
'<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-primary);margin-bottom:8px;">' + price + '</div>' +
|
||||
'<button class="btn btn--primary btn--sm" style="width:100%;" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
|
||||
'<div class="part-card__footer">' +
|
||||
stockBadge +
|
||||
'<span class="part-card__price">' + price + '</span>' +
|
||||
'</div>' +
|
||||
'<button class="btn btn-primary" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
html += this.renderPagination();
|
||||
|
||||
this.setContent(html);
|
||||
},
|
||||
|
||||
renderPagination: function() {
|
||||
var hasPrev = this._offset > 0;
|
||||
var hasNext = (this._offset + this._limit) < this._total;
|
||||
var pageNum = Math.floor(this._offset / this._limit) + 1;
|
||||
var totalPages = Math.ceil(this._total / this._limit) || 1;
|
||||
html += '<div style="grid-column:1/-1;display:flex;justify-content:center;align-items:center;gap:var(--space-3);padding:var(--space-4) 0;">' +
|
||||
'<button class="btn btn--secondary" ' + (hasPrev ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
|
||||
var html = '<div class="pagination">' +
|
||||
'<button class="page-item" ' + (hasPrev ? '' : 'disabled') +
|
||||
' onclick="BrandCatalog.goToPage(' + (this._offset - this._limit) + ')">← Anterior</button>' +
|
||||
'<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">Pagina ' + pageNum + ' de ' + totalPages + '</span>' +
|
||||
'<button class="btn btn--secondary" ' + (hasNext ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
|
||||
'<button class="page-item" ' + (hasNext ? '' : 'disabled') +
|
||||
' onclick="BrandCatalog.goToPage(' + (this._offset + this._limit) + ')">Siguiente →</button>' +
|
||||
'</div>';
|
||||
|
||||
this.setContent(html);
|
||||
return html;
|
||||
},
|
||||
|
||||
searchParts: function(term) {
|
||||
@@ -451,16 +497,17 @@
|
||||
return;
|
||||
}
|
||||
if (window.CatalogApp && CatalogApp.addToCart) {
|
||||
var isAftermarket = !!part.manufacturer;
|
||||
CatalogApp.addToCart({
|
||||
id: part.id,
|
||||
id: part.oem_id || part.id,
|
||||
part_number: part.oem_part_number || 'N/A',
|
||||
name: part.name || 'Refaccion',
|
||||
brand: '',
|
||||
price: part.local_price || 0,
|
||||
brand: part.manufacturer || '',
|
||||
price: part.local_price || part.price_usd || 0,
|
||||
tax_rate: 0.16,
|
||||
unit: 'PZA',
|
||||
stock: part.local_stock || 0,
|
||||
source: 'oem-brand',
|
||||
source: isAftermarket ? 'aftermarket' : 'oem-brand',
|
||||
inventory_id: null
|
||||
}, 1);
|
||||
var btn = event.target;
|
||||
|
||||
@@ -349,10 +349,14 @@
|
||||
var cacheKey = 'nexus:brands:' + catalogMode;
|
||||
var cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
hideLoading();
|
||||
var data = JSON.parse(cached);
|
||||
renderBrands(data);
|
||||
return;
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
|
||||
@@ -1631,8 +1635,13 @@
|
||||
var cacheKey = 'nexus:years-all';
|
||||
var cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
var data = JSON.parse(cached);
|
||||
var years = data.data || data || [];
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(cacheKey);
|
||||
var years = [];
|
||||
}
|
||||
if (!years.length) {
|
||||
years = [];
|
||||
for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y });
|
||||
|
||||
1
pos/static/js/catalog.min.js
vendored
1
pos/static/js/catalog.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/chat.min.js
vendored
1
pos/static/js/chat.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -19,6 +19,11 @@ const Config = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
@@ -623,6 +628,67 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Allowed Part Brands
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadAllowedBrands() {
|
||||
var container = document.getElementById('allowed-brands-container');
|
||||
if (!container) return;
|
||||
try {
|
||||
var res = await fetch(API + '/available-brands', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load brands');
|
||||
var d = await res.json();
|
||||
var allBrands = d.brands || [];
|
||||
|
||||
var res2 = await fetch(API + '/allowed-brands', { headers: headers() });
|
||||
if (!res2.ok) throw new Error('Failed to load allowed brands');
|
||||
var d2 = await res2.json();
|
||||
var allowed = d2.brands || [];
|
||||
|
||||
if (!allBrands.length) {
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">No hay marcas disponibles.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
allBrands.forEach(function(b) {
|
||||
var checked = allowed.indexOf(b) !== -1 ? 'checked' : '';
|
||||
html += '<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);color:var(--color-text-primary);padding:var(--space-1);">' +
|
||||
'<input type="checkbox" value="' + escapeHtml(b) + '" data-brand-checkbox ' + checked + ' style="width:16px;height:16px;cursor:pointer;">' +
|
||||
escapeHtml(b) + '</label>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
console.error('Config.loadAllowedBrands:', e);
|
||||
if (container) container.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-body-sm);">Error al cargar marcas.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAllowedBrands() {
|
||||
var btn = document.getElementById('btn-save-allowed-brands');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
var checked = [];
|
||||
document.querySelectorAll('[data-brand-checkbox]').forEach(function(cb) {
|
||||
if (cb.checked) checked.push(cb.value);
|
||||
});
|
||||
var res = await fetch(API + '/allowed-brands', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ brands: checked })
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Save failed');
|
||||
}
|
||||
toast('Marcas permitidas actualizadas');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Marcas'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -650,6 +716,12 @@ const Config = (() => {
|
||||
btnCompat.addEventListener('click', saveVehicleCompatSource);
|
||||
}
|
||||
|
||||
// Allowed brands save button
|
||||
var btnBrands = document.getElementById('btn-save-allowed-brands');
|
||||
if (btnBrands) {
|
||||
btnBrands.addEventListener('click', saveAllowedBrands);
|
||||
}
|
||||
|
||||
// Kiosk mode toggle
|
||||
var kioskToggle = document.getElementById('cfg-kiosk-mode');
|
||||
if (kioskToggle && window.NexusKiosk) {
|
||||
@@ -671,12 +743,13 @@ const Config = (() => {
|
||||
loadBusiness();
|
||||
loadCurrency();
|
||||
loadVehicleCompatSource();
|
||||
loadAllowedBrands();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
return {
|
||||
init, setTheme, selectThemeOption,
|
||||
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
|
||||
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||
loadBusiness, saveBusiness, saveTaxParams,
|
||||
loadCurrency, saveCurrency,
|
||||
|
||||
1
pos/static/js/config.min.js
vendored
1
pos/static/js/config.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -104,7 +104,7 @@ const Customers = (() => {
|
||||
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
|
||||
const num = String(c.id).padStart(5, '0');
|
||||
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
|
||||
return '<tr class="' + selClass + '" onclick="selectCustomer(' + c.id + ')">' +
|
||||
return '<tr class="' + selClass + '" onclick="Customers.selectCustomer(' + c.id + ')">' +
|
||||
'<td class="cell-num">' + num + '</td>' +
|
||||
'<td>' +
|
||||
'<div class="cell-name">' + (c.name || '') + '</div>' +
|
||||
@@ -263,6 +263,13 @@ const Customers = (() => {
|
||||
bar.className = `progress-bar__fill ${pct < 40 ? 'low' : pct > 75 ? 'high' : ''}`;
|
||||
}
|
||||
|
||||
// Discount
|
||||
const discountEl = document.getElementById('detailMaxDiscount');
|
||||
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
|
||||
|
||||
// Re-wire action buttons after detail panel is visible
|
||||
wireActionButtons();
|
||||
|
||||
// Purchase History
|
||||
const hbody = document.getElementById('historyBody');
|
||||
if (hbody) {
|
||||
@@ -363,7 +370,7 @@ const Customers = (() => {
|
||||
const btns = document.querySelectorAll('.quick-actions .action-btn');
|
||||
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
|
||||
if (btns.length >= 1) btns[0].onclick = () => {
|
||||
if (currentCustomer) window.location.href = '/pos/?customer=' + currentCustomer.id;
|
||||
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
|
||||
};
|
||||
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
|
||||
if (btns.length >= 3) btns[2].onclick = () => showStatement();
|
||||
@@ -378,17 +385,19 @@ const Customers = (() => {
|
||||
if (!modal) return;
|
||||
document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
|
||||
document.getElementById('editId').value = '';
|
||||
document.getElementById('fName').value = '';
|
||||
document.getElementById('fRfc').value = '';
|
||||
document.getElementById('fRazonSocial').value = '';
|
||||
document.getElementById('fRegimenFiscal').value = '';
|
||||
document.getElementById('fUsoCfdi').value = 'G03';
|
||||
document.getElementById('fCp').value = '';
|
||||
document.getElementById('fPhone').value = '';
|
||||
document.getElementById('fEmail').value = '';
|
||||
document.getElementById('fAddress').value = '';
|
||||
document.getElementById('fPriceTier').value = '1';
|
||||
document.getElementById('fCreditLimit').value = '0';
|
||||
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||||
safeSet('fName', '');
|
||||
safeSet('fRfc', '');
|
||||
safeSet('fRazonSocial', '');
|
||||
safeSet('fRegimenFiscal', '');
|
||||
safeSet('fUsoCfdi', 'G03');
|
||||
safeSet('fCp', '');
|
||||
safeSet('fPhone', '');
|
||||
safeSet('fEmail', '');
|
||||
safeSet('fAddress', '');
|
||||
safeSet('fPriceTier', '1');
|
||||
safeSet('fCreditLimit', '0');
|
||||
safeSet('fMaxDiscountPct', '0');
|
||||
modal.classList.add('active');
|
||||
document.getElementById('fName').focus();
|
||||
}
|
||||
@@ -400,17 +409,19 @@ const Customers = (() => {
|
||||
if (!modal) return;
|
||||
document.getElementById('modalTitle').textContent = 'Editar Cliente';
|
||||
document.getElementById('editId').value = c.id;
|
||||
document.getElementById('fName').value = c.name || '';
|
||||
document.getElementById('fRfc').value = c.rfc || '';
|
||||
document.getElementById('fRazonSocial').value = c.razon_social || '';
|
||||
document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
|
||||
document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
|
||||
document.getElementById('fCp').value = c.cp || '';
|
||||
document.getElementById('fPhone').value = c.phone || '';
|
||||
document.getElementById('fEmail').value = c.email || '';
|
||||
document.getElementById('fAddress').value = c.address || '';
|
||||
document.getElementById('fPriceTier').value = c.price_tier || '1';
|
||||
document.getElementById('fCreditLimit').value = c.credit_limit || 0;
|
||||
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||||
safeSet('fName', c.name || '');
|
||||
safeSet('fRfc', c.rfc || '');
|
||||
safeSet('fRazonSocial', c.razon_social || '');
|
||||
safeSet('fRegimenFiscal', c.regimen_fiscal || '');
|
||||
safeSet('fUsoCfdi', c.uso_cfdi || 'G03');
|
||||
safeSet('fCp', c.cp || '');
|
||||
safeSet('fPhone', c.phone || '');
|
||||
safeSet('fEmail', c.email || '');
|
||||
safeSet('fAddress', c.address || '');
|
||||
safeSet('fPriceTier', c.price_tier || '1');
|
||||
safeSet('fCreditLimit', c.credit_limit || 0);
|
||||
safeSet('fMaxDiscountPct', c.max_discount_pct || 0);
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
@@ -438,6 +449,7 @@ const Customers = (() => {
|
||||
address: val('fAddress') || null,
|
||||
price_tier: parseInt(val('fPriceTier')) || 1,
|
||||
credit_limit: parseFloat(val('fCreditLimit')) || 0,
|
||||
max_discount_pct: parseFloat(val('fMaxDiscountPct')) || 0,
|
||||
};
|
||||
|
||||
const editId = val('editId');
|
||||
@@ -586,7 +598,9 @@ const Customers = (() => {
|
||||
|
||||
function injectModals() {
|
||||
// Customer Create/Edit Modal
|
||||
if (!document.getElementById('customerModal')) {
|
||||
// Always remove and re-inject to ensure latest fields are present
|
||||
const existingModal = document.getElementById('customerModal');
|
||||
if (existingModal) existingModal.remove();
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = `
|
||||
<div id="customerModal" class="modal-overlay" style="display:none;">
|
||||
@@ -646,6 +660,7 @@ const Customers = (() => {
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Limite de Credito</label><input type="number" id="fCreditLimit" class="form-input" value="0" min="0" step="1000" /></div>
|
||||
<div class="form-group"><label>Descuento Max (%)</label><input type="number" id="fMaxDiscountPct" class="form-input" value="0" min="0" max="100" step="0.5" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -655,9 +670,10 @@ const Customers = (() => {
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
// Statement Modal
|
||||
const existingStatement = document.getElementById('statementModal');
|
||||
if (existingStatement) existingStatement.remove();
|
||||
if (!document.getElementById('statementModal')) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = `
|
||||
@@ -772,11 +788,15 @@ const Customers = (() => {
|
||||
// Run init
|
||||
init();
|
||||
|
||||
return {
|
||||
const publicApi = {
|
||||
search, goToPage, loadCustomers,
|
||||
showDetail, selectCustomer, closeDetail,
|
||||
showCreateModal, editCurrent, closeModal, save,
|
||||
showStatement, closeStatement,
|
||||
showPaymentModal, closePayment, recordPayment,
|
||||
};
|
||||
|
||||
// Expose globally for inline HTML onclick handlers
|
||||
window.Customers = publicApi;
|
||||
return publicApi;
|
||||
})();
|
||||
|
||||
1
pos/static/js/customers.min.js
vendored
1
pos/static/js/customers.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/dashboard.min.js
vendored
1
pos/static/js/dashboard.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/diagrams.min.js
vendored
1
pos/static/js/diagrams.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/fleet.min.js
vendored
1
pos/static/js/fleet.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -47,6 +47,79 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// --- Dashboard summary badges ---
|
||||
function loadSummary() {
|
||||
apiFetch(API + '/summary').then(function(data) {
|
||||
if (!data) return;
|
||||
var totalSkusEl = document.getElementById('inv-total-skus');
|
||||
var totalValueEl = document.getElementById('inv-total-value');
|
||||
var lowStockEl = document.getElementById('inv-low-stock');
|
||||
var noMovementEl = document.getElementById('inv-no-movement');
|
||||
if (totalSkusEl) totalSkusEl.textContent = (data.total_skus || 0).toLocaleString('es-MX');
|
||||
if (totalValueEl) totalValueEl.textContent = '$' + (data.total_value || 0).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
if (lowStockEl) lowStockEl.textContent = (data.low_stock || 0).toLocaleString('es-MX');
|
||||
if (noMovementEl) noMovementEl.textContent = (data.no_movement || 0).toLocaleString('es-MX');
|
||||
}).catch(function(err) {
|
||||
console.error('Inventory summary load failed:', err);
|
||||
});
|
||||
}
|
||||
loadSummary();
|
||||
|
||||
// --- Global tier discounts ---
|
||||
var globalDiscounts = { 2: 15, 3: 25 };
|
||||
function loadTierDiscounts() {
|
||||
apiFetch(API + '/tier-discounts').then(function(data) {
|
||||
if (data && data.data) {
|
||||
data.data.forEach(function(d) {
|
||||
globalDiscounts[d.tier_id] = d.discount_pct;
|
||||
});
|
||||
}
|
||||
var discEl = document.getElementById('tierDiscountBadge');
|
||||
if (discEl) {
|
||||
discEl.textContent = 'Taller -' + globalDiscounts[2] + '% · Mayoreo -' + globalDiscounts[3] + '%';
|
||||
}
|
||||
});
|
||||
}
|
||||
loadTierDiscounts();
|
||||
|
||||
function showTierDiscountModal() {
|
||||
document.getElementById('tierDisc2').value = globalDiscounts[2];
|
||||
document.getElementById('tierDisc3').value = globalDiscounts[3];
|
||||
document.getElementById('tierDiscountModal').classList.add('is-open');
|
||||
}
|
||||
function closeTierDiscountModal() {
|
||||
document.getElementById('tierDiscountModal').classList.remove('is-open');
|
||||
}
|
||||
function saveTierDiscounts() {
|
||||
var d2 = parseFloat(document.getElementById('tierDisc2').value) || 0;
|
||||
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
|
||||
fetch(API + '/tier-discounts', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
showToast(res.message || 'Guardado', 'ok');
|
||||
globalDiscounts[2] = d2;
|
||||
globalDiscounts[3] = d3;
|
||||
var discEl = document.getElementById('tierDiscountBadge');
|
||||
if (discEl) {
|
||||
discEl.textContent = 'Taller -' + d2 + '% · Mayoreo -' + d3 + '%';
|
||||
}
|
||||
closeTierDiscountModal();
|
||||
}).catch(function() {
|
||||
showToast('Error al guardar descuentos', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle hash-based tab switching (e.g. /pos/inventory#alertas)
|
||||
(function handleHashTab() {
|
||||
var hash = window.location.hash.replace('#', '');
|
||||
if (hash && ['stock', 'entradas', 'salidas', 'traspasos', 'ajustes', 'conteos', 'alertas'].indexOf(hash) !== -1) {
|
||||
setTimeout(function() { switchTab(hash); }, 100);
|
||||
}
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// TAB SWITCHING — uses design-system switchTab() already in the HTML.
|
||||
// We hook into it to trigger data loads when tabs are activated.
|
||||
@@ -63,8 +136,12 @@
|
||||
// STOCK / PRODUCTS (panel-stock)
|
||||
// =====================================================================
|
||||
|
||||
var selectedItems = new Set();
|
||||
|
||||
function renderInventoryRow(it) {
|
||||
var isChecked = selectedItems.has(it.id) ? 'checked' : '';
|
||||
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
|
||||
'<td onclick="event.stopPropagation();"><input type="checkbox" class="item-checkbox" data-id="' + it.id + '" ' + isChecked + ' onclick="event.stopPropagation();toggleItemSelection(' + it.id + ')"></td>' +
|
||||
'<td class="td--mono" style="font-size:var(--text-caption);color:var(--color-text-muted);">' + it.id + '</td>' +
|
||||
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
|
||||
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
|
||||
@@ -79,10 +156,50 @@
|
||||
'<td>' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button>' +
|
||||
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
|
||||
'</td></tr>';
|
||||
}
|
||||
|
||||
window.toggleItemSelection = function(id) {
|
||||
if (selectedItems.has(id)) {
|
||||
selectedItems.delete(id);
|
||||
} else {
|
||||
selectedItems.add(id);
|
||||
}
|
||||
updateSelectionUI();
|
||||
};
|
||||
|
||||
window.toggleSelectAllItems = function() {
|
||||
var cb = document.getElementById('selectAllItems');
|
||||
var allChecked = cb.checked;
|
||||
// We need to get all visible items from inventoryVS
|
||||
if (inventoryVS && inventoryVS.data) {
|
||||
inventoryVS.data.forEach(function(it) {
|
||||
if (allChecked) selectedItems.add(it.id);
|
||||
else selectedItems.delete(it.id);
|
||||
});
|
||||
inventoryVS.refresh();
|
||||
}
|
||||
updateSelectionUI();
|
||||
};
|
||||
|
||||
function updateSelectionUI() {
|
||||
var count = selectedItems.size;
|
||||
var btn = document.getElementById('btnPublishML');
|
||||
var badge = document.getElementById('meliSelectedCountBadge');
|
||||
if (btn) btn.style.display = count > 0 ? 'inline-flex' : 'none';
|
||||
if (badge) badge.textContent = count;
|
||||
// Update select-all checkbox state
|
||||
var selectAll = document.getElementById('selectAllItems');
|
||||
if (selectAll && inventoryVS && inventoryVS.data) {
|
||||
var visibleIds = inventoryVS.data.map(function(it) { return it.id; });
|
||||
var allSelected = visibleIds.length > 0 && visibleIds.every(function(id) { return selectedItems.has(id); });
|
||||
selectAll.checked = allSelected;
|
||||
}
|
||||
}
|
||||
|
||||
function loadItems(page, search) {
|
||||
currentPage = page || 1;
|
||||
currentSearch = search !== undefined ? search : currentSearch;
|
||||
@@ -220,7 +337,7 @@
|
||||
loadItems(currentPage);
|
||||
// Close modal, clear form, refresh badges
|
||||
closeCreateModal();
|
||||
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newPrice2','newPrice3','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
|
||||
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
@@ -634,6 +751,195 @@
|
||||
document.getElementById('historyModal').classList.remove('is-open');
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DELETE ITEM
|
||||
// =====================================================================
|
||||
|
||||
function deleteItem(itemId) {
|
||||
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
|
||||
var token = localStorage.getItem('pos_token') || '';
|
||||
fetch(API + '/items/' + itemId, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { alert('Error: ' + data.error); return; }
|
||||
showToast('Artículo eliminado');
|
||||
loadItems(currentPage);
|
||||
if (window.loadInventoryStats) window.loadInventoryStats();
|
||||
}).catch(function() { alert('Error al eliminar artículo'); });
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// MERCADOLIBRE PUBLISH
|
||||
// =====================================================================
|
||||
|
||||
function publishToMeli(itemId) {
|
||||
selectedItems.clear();
|
||||
selectedItems.add(itemId);
|
||||
updateSelectionUI();
|
||||
openMeliPublishModal();
|
||||
}
|
||||
window.publishToMeli = publishToMeli;
|
||||
|
||||
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
|
||||
|
||||
window.openMeliPublishModal = function() {
|
||||
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
|
||||
document.getElementById('meliPublishModal').classList.add('is-open');
|
||||
document.getElementById('meliPublishResult').innerHTML = '';
|
||||
document.getElementById('meliCategoryId').value = '';
|
||||
document.getElementById('meliCategorySearch').value = '';
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
refreshMeliPublishPreview();
|
||||
};
|
||||
|
||||
window.closeMeliPublishModal = function() {
|
||||
document.getElementById('meliPublishModal').classList.remove('is-open');
|
||||
};
|
||||
|
||||
function refreshMeliPublishPreview() {
|
||||
var container = document.getElementById('meliPublishItemsPreview');
|
||||
var countEl = document.getElementById('meliPublishSelectedCount');
|
||||
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
|
||||
if (!inventoryVS || !inventoryVS.data) { container.innerHTML = '<p style="color:var(--color-text-muted);">Sin datos</p>'; return; }
|
||||
var items = inventoryVS.data.filter(function(it) { return selectedItems.has(it.id); });
|
||||
if (!items.length) { container.innerHTML = '<p style="color:var(--color-text-muted);">Ninguno</p>'; return; }
|
||||
var html = '<table class="data-table" style="font-size:var(--text-caption);"><thead><tr><th>ID</th><th>No. Parte</th><th>Nombre</th><th>Stock</th><th style="text-align:right">Precio</th></tr></thead><tbody>';
|
||||
items.forEach(function(it) {
|
||||
html += '<tr><td>' + it.id + '</td><td class="td--mono">' + esc(it.part_number) + '</td><td>' + esc(it.name) + '</td><td>' + it.stock + '</td><td style="text-align:right">$' + fmt(it.price_1) + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
var meliCategorySearchTimeout;
|
||||
var meliCatItems = [];
|
||||
var meliCatActiveIndex = -1;
|
||||
|
||||
window.searchMeliCategories = function() {
|
||||
var q = document.getElementById('meliCategorySearch').value.trim();
|
||||
var resultsDiv = document.getElementById('meliCategoryResults');
|
||||
if (q.length < 2) { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; return; }
|
||||
clearTimeout(meliCategorySearchTimeout);
|
||||
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
|
||||
meliCategorySearchTimeout = setTimeout(function() {
|
||||
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var cats = data.categories || [];
|
||||
meliCatItems = cats.slice(0, 10);
|
||||
meliCatActiveIndex = -1;
|
||||
if (!meliCatItems.length) { resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-empty">Sin resultados</div></div>'; return; }
|
||||
var html = '<div class="meli-cat-dropdown">';
|
||||
meliCatItems.forEach(function(c, idx) {
|
||||
html += '<div class="meli-cat-item" data-idx="' + idx + '" onmouseenter="highlightMeliCat(' + idx + ')" onmousedown="selectMeliCategoryIdx(' + idx + ')">' +
|
||||
'<span>' + esc(c.category_name || c.category_id) + '</span>' +
|
||||
'<span class="cat-id">' + esc(c.category_id) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
})
|
||||
.catch(function() { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.highlightMeliCat = function(idx) {
|
||||
meliCatActiveIndex = idx;
|
||||
var items = document.querySelectorAll('.meli-cat-item');
|
||||
items.forEach(function(el, i) { el.classList.toggle('is-active', i === idx); });
|
||||
};
|
||||
|
||||
window.selectMeliCategoryIdx = function(idx) {
|
||||
var c = meliCatItems[idx];
|
||||
if (!c) return;
|
||||
selectMeliCategory(c.category_id, c.category_name || c.category_id);
|
||||
};
|
||||
|
||||
window.selectMeliCategory = function(id, name) {
|
||||
document.getElementById('meliCategoryId').value = id;
|
||||
document.getElementById('meliCategorySearch').value = name;
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
};
|
||||
|
||||
window.handleMeliCatKeydown = function(e) {
|
||||
if (!meliCatItems.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.min(meliCatActiveIndex + 1, meliCatItems.length - 1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.max(meliCatActiveIndex - 1, -1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (meliCatActiveIndex >= 0) selectMeliCategoryIdx(meliCatActiveIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
};
|
||||
|
||||
/* Cerrar dropdown al hacer click fuera */
|
||||
document.addEventListener('click', function(e) {
|
||||
var field = document.getElementById('meliCategorySearch');
|
||||
var results = document.getElementById('meliCategoryResults');
|
||||
if (field && results && !field.contains(e.target) && !results.contains(e.target)) {
|
||||
results.innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
window.executeMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliPublishBtn');
|
||||
btn.disabled = true;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando ' + ids.length + ' producto(s)...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listings', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode
|
||||
})
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
var success = (data.success || []).length;
|
||||
var failedList = data.failed || [];
|
||||
var failed = failedList.length;
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
|
||||
if (failedList.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
failedList.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
if (success > 0) {
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 2500);
|
||||
}
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// BARCODE LABEL PRINT
|
||||
// =====================================================================
|
||||
@@ -747,9 +1053,9 @@
|
||||
// Prices
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Costo</span><span class="td--amount">$' + fmt(data.cost) + '</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 1</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 2</span><span class="td--amount">$' + fmt(data.price_2) + '</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 3</span><span class="td--amount">$' + fmt(data.price_3) + '</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mostrador</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Taller</span><span class="td--amount">$' + fmt(data.price_2) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[2] + '%)</span></div>';
|
||||
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mayoreo</span><span class="td--amount">$' + fmt(data.price_3) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[3] + '%)</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
// Cross-references section
|
||||
@@ -950,6 +1256,7 @@
|
||||
window.cancelDraft = cancelDraft;
|
||||
window.loadAlerts = loadAlerts;
|
||||
window.printBarcode = printBarcode;
|
||||
window.deleteItem = deleteItem;
|
||||
window.autoMatchCompat = autoMatchCompat;
|
||||
window.removeCompat = removeCompat;
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Expose for other scripts
|
||||
window.isKioskEnabled = isKioskEnabled;
|
||||
|
||||
// ─── Init ───
|
||||
if (isKioskEnabled()) {
|
||||
activate();
|
||||
|
||||
367
pos/static/js/marketplace_external.js
Normal file
367
pos/static/js/marketplace_external.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* marketplace_external.js — MercadoLibre integration UI
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var API = '/pos/api/marketplace-ext';
|
||||
var TOKEN = localStorage.getItem('pos_token') || '';
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Authorization': 'Bearer ' + TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': localStorage.getItem('pos_device_id') || 'web',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
window.switchTab = function(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) {
|
||||
b.classList.toggle('is-active', b.dataset.tab === tab);
|
||||
b.setAttribute('aria-selected', b.dataset.tab === tab ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) {
|
||||
p.classList.toggle('is-active', p.id === 'panel-' + tab);
|
||||
});
|
||||
if (tab === 'listings') loadListings();
|
||||
if (tab === 'orders') loadOrders();
|
||||
};
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('is-open');
|
||||
}
|
||||
window.closeModal = closeModal;
|
||||
|
||||
// ─── Config / Connection ───────────────────────────────────────────────
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
var res = await fetch(API + '/config', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load config');
|
||||
var cfg = await res.json();
|
||||
|
||||
var statusDiv = document.getElementById('configStatus');
|
||||
var formDiv = document.getElementById('configForm');
|
||||
var connectedDiv = document.getElementById('configConnected');
|
||||
|
||||
if (cfg.connected) {
|
||||
statusDiv.innerHTML = '<span class="meli-status meli-status--active">● Conectado</span>';
|
||||
formDiv.style.display = 'none';
|
||||
connectedDiv.style.display = 'block';
|
||||
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
|
||||
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
|
||||
formDiv.style.display = 'block';
|
||||
connectedDiv.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('configStatus').innerHTML = '<p style="color:var(--color-danger);">Error cargando configuración</p>';
|
||||
}
|
||||
}
|
||||
|
||||
window.startOAuth = function() {
|
||||
var clientId = document.getElementById('cfgClientId').value.trim();
|
||||
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
||||
var category = document.getElementById('cfgCategory').value.trim();
|
||||
var shipping = document.getElementById('cfgShipping').value;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
alert('Client ID y Client Secret son requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config locally for the callback
|
||||
localStorage.setItem('meli_client_id', clientId);
|
||||
localStorage.setItem('meli_client_secret', clientSecret);
|
||||
localStorage.setItem('meli_category', category);
|
||||
localStorage.setItem('meli_shipping', shipping);
|
||||
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri);
|
||||
window.location.href = authUrl;
|
||||
};
|
||||
|
||||
window.disconnectMeli = async function() {
|
||||
if (!confirm('¿Desconectar MercadoLibre? Las publicaciones existentes no se eliminarán de ML.')) return;
|
||||
try {
|
||||
var res = await fetch(API + '/connect', { method: 'DELETE', headers: headers() });
|
||||
if (res.ok) {
|
||||
loadConfig();
|
||||
} else {
|
||||
alert('Error desconectando');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Listings ──────────────────────────────────────────────────────────
|
||||
|
||||
var listingsData = [];
|
||||
|
||||
window.loadListings = async function() {
|
||||
var container = document.getElementById('listingsContainer');
|
||||
container.innerHTML = '<p>Cargando...</p>';
|
||||
try {
|
||||
var res = await fetch(API + '/listings?page=1&per_page=50', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load listings');
|
||||
var data = await res.json();
|
||||
listingsData = data.items || [];
|
||||
renderListings();
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p style="color:var(--color-danger);">Error cargando publicaciones</p>';
|
||||
}
|
||||
};
|
||||
|
||||
function renderListings() {
|
||||
var container = document.getElementById('listingsContainer');
|
||||
var statusFilter = document.getElementById('listingStatusFilter').value;
|
||||
var search = document.getElementById('listingSearch').value.toLowerCase();
|
||||
|
||||
var filtered = listingsData.filter(function(l) {
|
||||
if (statusFilter && l.external_status !== statusFilter) return false;
|
||||
if (search && !((l.title || '').toLowerCase().includes(search))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);padding:var(--space-4);">No hay publicaciones. Ve a Inventario y publica un producto.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(function(l) {
|
||||
var statusClass = 'meli-status--' + (l.external_status || 'pending');
|
||||
return '<div class="meli-card">'
|
||||
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
|
||||
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '</div>'
|
||||
+ '<span class="meli-status ' + statusClass + '">' + (l.external_status || '—') + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
|
||||
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—')
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);">'
|
||||
+ '<button class="btn btn--ghost btn--xs" onclick="syncListing(' + l.id + ')">Sync</button>'
|
||||
+ (l.external_status === 'active' ? '<button class="btn btn--ghost btn--xs" onclick="pauseListing(' + l.id + ')">Pausar</button>' : '<button class="btn btn--ghost btn--xs" onclick="activateListing(' + l.id + ')">Activar</button>')
|
||||
+ '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.filterListings = renderListings;
|
||||
|
||||
window.syncListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/sync', { method: 'POST', headers: headers() });
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('Sincronizado: $' + data.price + ' · Stock: ' + data.stock);
|
||||
loadListings();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.pauseListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/pause', { method: 'POST', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
window.activateListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/activate', { method: 'POST', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
window.deleteListing = async function(id) {
|
||||
if (!confirm('¿Cerrar esta publicación en MercadoLibre?')) return;
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id, { method: 'DELETE', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ─── Orders ────────────────────────────────────────────────────────────
|
||||
|
||||
var ordersData = [];
|
||||
|
||||
window.loadOrders = async function() {
|
||||
var tbody = document.getElementById('ordersTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">Cargando...</td></tr>';
|
||||
try {
|
||||
var res = await fetch(API + '/orders?page=1&per_page=50', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load orders');
|
||||
var data = await res.json();
|
||||
ordersData = data.items || [];
|
||||
renderOrders();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-danger)">Error cargando órdenes</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
function renderOrders() {
|
||||
var tbody = document.getElementById('ordersTableBody');
|
||||
var statusFilter = document.getElementById('orderStatusFilter').value;
|
||||
var search = document.getElementById('orderSearch').value.toLowerCase();
|
||||
|
||||
var filtered = ordersData.filter(function(o) {
|
||||
if (statusFilter && o.status !== statusFilter) return false;
|
||||
if (search && !((o.buyer_name || '').toLowerCase().includes(search) || (o.external_order_id || '').includes(search))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-text-muted)">No hay órdenes.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(function(o) {
|
||||
var statusClass = 'meli-status--' + (o.status || 'pending');
|
||||
return '<tr>'
|
||||
+ '<td><a href="#" onclick="showOrderDetail(' + o.id + ');return false;">' + escapeHtml(o.external_order_id) + '</a></td>'
|
||||
+ '<td>' + escapeHtml(o.buyer_name || o.buyer_nickname || '—') + '</td>'
|
||||
+ '<td style="text-align:right">$' + (o.total_amount || 0).toFixed(2) + '</td>'
|
||||
+ '<td><span class="meli-status ' + statusClass + '">' + (o.status || '—') + '</span></td>'
|
||||
+ '<td>' + (o.created_at ? o.created_at.split('T')[0] : '—') + '</td>'
|
||||
+ '<td>'
|
||||
+ (o.status === 'pending' ? '<button class="btn btn--primary btn--xs" onclick="convertOrder(' + o.id + ')">Convertir a Venta</button> ' : '')
|
||||
+ '<button class="btn btn--ghost btn--xs" onclick="showOrderDetail(' + o.id + ')">Ver</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.filterOrders = renderOrders;
|
||||
|
||||
window.showOrderDetail = async function(id) {
|
||||
var modal = document.getElementById('orderModal');
|
||||
var body = document.getElementById('orderModalBody');
|
||||
var footer = document.getElementById('orderModalFooter');
|
||||
body.innerHTML = 'Cargando...';
|
||||
footer.innerHTML = '';
|
||||
modal.classList.add('is-open');
|
||||
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id, { headers: headers() });
|
||||
var o = await res.json();
|
||||
if (!res.ok) throw new Error(o.error || 'Error');
|
||||
|
||||
var itemsHtml = (o.items || []).map(function(it) {
|
||||
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
body.innerHTML = '<div style="margin-bottom:var(--space-4);">'
|
||||
+ '<p><strong>Comprador:</strong> ' + escapeHtml(o.buyer_name || '—') + ' (' + escapeHtml(o.buyer_nickname || '—') + ')</p>'
|
||||
+ '<p><strong>Email:</strong> ' + escapeHtml(o.buyer_email || '—') + '</p>'
|
||||
+ '<p><strong>Teléfono:</strong> ' + escapeHtml(o.buyer_phone || '—') + '</p>'
|
||||
+ '<p><strong>Total:</strong> $' + (o.total_amount || 0).toFixed(2) + '</p>'
|
||||
+ '<p><strong>Estado ML:</strong> ' + escapeHtml(o.external_status || '—') + '</p>'
|
||||
+ '<p><strong>Estado Nexus:</strong> ' + escapeHtml(o.status || '—') + '</p>'
|
||||
+ '</div>'
|
||||
+ '<h4 style="margin:var(--space-3) 0;">Items</h4>'
|
||||
+ '<table class="data-table"><thead><tr><th>Producto</th><th>Cantidad</th><th style="text-align:right">Precio</th></tr></thead><tbody>' + itemsHtml + '</tbody></table>';
|
||||
|
||||
footer.innerHTML = '';
|
||||
if (o.status === 'pending') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="convertOrder(' + o.id + ');closeModal(\'orderModal\')">Convertir a Venta</button> ';
|
||||
}
|
||||
if (o.status === 'confirmed') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'packed\')">Marcar Empacada</button> ';
|
||||
}
|
||||
if (o.status === 'packed') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'shipped\')">Marcar Enviada</button> ';
|
||||
}
|
||||
footer.innerHTML += '<button class="btn btn--ghost" onclick="closeModal(\'orderModal\')">Cerrar</button>';
|
||||
} catch (e) {
|
||||
body.innerHTML = '<p style="color:var(--color-danger)">Error: ' + escapeHtml(e.message) + '</p>';
|
||||
}
|
||||
};
|
||||
|
||||
window.convertOrder = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id + '/convert', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('Orden convertida a venta #' + data.sale_id);
|
||||
loadOrders();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.updateOrderStatus = async function(id, status) {
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id + '/status', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ status: status })
|
||||
});
|
||||
if (res.ok) { loadOrders(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ─── Utils ─────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Handle OAuth callback
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var authCode = urlParams.get('code');
|
||||
if (authCode && window.location.pathname.includes('marketplace-external')) {
|
||||
(async function() {
|
||||
var clientId = localStorage.getItem('meli_client_id');
|
||||
var clientSecret = localStorage.getItem('meli_client_secret');
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
try {
|
||||
var res = await fetch(API + '/connect', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('¡Conectado exitosamente con MercadoLibre!');
|
||||
window.history.replaceState({}, document.title, '/pos/marketplace-external');
|
||||
loadConfig();
|
||||
} else {
|
||||
alert('Error conectando: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfig();
|
||||
});
|
||||
})();
|
||||
@@ -123,6 +123,8 @@ const POS = (() => {
|
||||
currentRegister = null;
|
||||
document.getElementById('registerInfo').innerHTML =
|
||||
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
||||
// Force open register modal on first load
|
||||
showOpenRegisterModal();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Register check failed:', e);
|
||||
@@ -253,6 +255,9 @@ const POS = (() => {
|
||||
discount_pct: parseFloat(item.discount_pct || 0),
|
||||
tax_rate: parseFloat(item.tax_rate || 0.16),
|
||||
stock: item.stock || 0,
|
||||
price_1: parseFloat(item.price_1 || 0),
|
||||
price_2: parseFloat(item.price_2 || 0),
|
||||
price_3: parseFloat(item.price_3 || 0),
|
||||
});
|
||||
|
||||
renderCart();
|
||||
@@ -265,6 +270,67 @@ const POS = (() => {
|
||||
renderCart();
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
cart.length = 0;
|
||||
selectedRow = -1;
|
||||
renderCart();
|
||||
}
|
||||
|
||||
function openCancelModal() {
|
||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||
const dialog = document.getElementById('modal-cancelar-venta');
|
||||
if (overlay) overlay.classList.add('active');
|
||||
if (dialog) dialog.classList.add('active');
|
||||
}
|
||||
|
||||
function closeCancelModal() {
|
||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||
const dialog = document.getElementById('modal-cancelar-venta');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
if (dialog) dialog.classList.remove('active');
|
||||
}
|
||||
|
||||
function changeQuantity() {
|
||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||
showToast('Selecciona un articulo primero', 'warn');
|
||||
return;
|
||||
}
|
||||
const q = prompt('Nueva cantidad:', cart[selectedRow].quantity);
|
||||
if (q !== null) {
|
||||
const n = parseInt(q);
|
||||
if (n > 0) {
|
||||
cart[selectedRow].quantity = n;
|
||||
renderCart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDiscount() {
|
||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||
showToast('Selecciona un articulo primero', 'warn');
|
||||
return;
|
||||
}
|
||||
const d = prompt('Descuento %:', cart[selectedRow].discount_pct);
|
||||
if (d !== null) {
|
||||
const n = parseFloat(d);
|
||||
if (n >= 0 && n <= 100) {
|
||||
cart[selectedRow].discount_pct = n;
|
||||
renderCart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wire confirm-cancel button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('btnConfirmCancel');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
clearCart();
|
||||
closeCancelModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function renderCart() {
|
||||
const tbody = document.getElementById('cartBody');
|
||||
const table = document.getElementById('cartTable');
|
||||
@@ -472,6 +538,9 @@ const POS = (() => {
|
||||
cost: item.cost,
|
||||
tax_rate: item.tax_rate,
|
||||
stock: item.stock,
|
||||
price_1: item.price_1,
|
||||
price_2: item.price_2,
|
||||
price_3: item.price_3,
|
||||
});
|
||||
hideSearchResults();
|
||||
document.getElementById('itemSearch').value = '';
|
||||
@@ -530,11 +599,22 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function recalcCartPrices() {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
cart.forEach(item => {
|
||||
if (item.price_1 > 0) {
|
||||
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function selectCustomer(customer) {
|
||||
currentCustomer = customer;
|
||||
document.getElementById('customerAutocomplete').style.display = 'none';
|
||||
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
|
||||
|
||||
recalcCartPrices();
|
||||
|
||||
const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
|
||||
document.getElementById('customerName').textContent = customer.name;
|
||||
document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
|
||||
@@ -1255,7 +1335,7 @@ const POS = (() => {
|
||||
init();
|
||||
|
||||
return {
|
||||
addToCart, removeFromCart, selectRow,
|
||||
addToCart, removeFromCart, clearCart, selectRow,
|
||||
updateQty, updateDiscount,
|
||||
addFromSearch, hideSearchResults,
|
||||
selectCustomer, clearCustomer,
|
||||
@@ -1268,5 +1348,6 @@ const POS = (() => {
|
||||
connectThermal, thermalPrint,
|
||||
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
|
||||
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
||||
openCancelModal, closeCancelModal, changeQuantity, applyDiscount,
|
||||
};
|
||||
})();
|
||||
|
||||
2
pos/static/js/pos.min.js
vendored
2
pos/static/js/pos.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -29,6 +29,7 @@
|
||||
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
|
||||
{ name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
|
||||
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||
@@ -180,4 +181,7 @@
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
window.closeSidebar = closeSidebar;
|
||||
|
||||
// Reveal sidebar smoothly after replacement to avoid flash/stun
|
||||
document.body.classList.add('sidebar-ready');
|
||||
|
||||
})();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
var activePhone = null;
|
||||
var pollTimer = null;
|
||||
var statusPollTimer = null;
|
||||
var qrPollTimer = null;
|
||||
var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown'
|
||||
|
||||
// -- Helpers ---------------------------------------------------------------
|
||||
@@ -88,6 +89,10 @@
|
||||
api('GET', '/status').then(function (data) {
|
||||
var state = (data.instance || data).state || data.state || 'close';
|
||||
updateConnectionUI(state);
|
||||
// If bridge already has a QR ready, show it immediately
|
||||
if (state === 'qr' || state === 'connecting') {
|
||||
fetchQR();
|
||||
}
|
||||
}).catch(function () {
|
||||
updateConnectionUI('close');
|
||||
});
|
||||
@@ -106,7 +111,8 @@
|
||||
// Load conversations + start polling on page load / reconnect
|
||||
loadConversations();
|
||||
startPolling();
|
||||
} else if (state === 'connecting') {
|
||||
stopQRPolling();
|
||||
} else if (state === 'connecting' || state === 'qr') {
|
||||
statusDot.className = 'status-dot status-dot--warn';
|
||||
statusText.textContent = 'Escaneando QR...';
|
||||
connectSection.style.display = 'flex';
|
||||
@@ -125,6 +131,7 @@
|
||||
refreshQrBtn.style.display = 'none';
|
||||
qrImg.style.display = 'none';
|
||||
qrPlaceholder.style.display = '';
|
||||
stopQRPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +148,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Instance created, now fetch QR
|
||||
fetchQR();
|
||||
// Switch UI to connecting state immediately
|
||||
updateConnectionUI('connecting');
|
||||
qrPlaceholder.textContent = 'Iniciando conexion con WhatsApp, generando QR...';
|
||||
qrPlaceholder.style.display = '';
|
||||
qrImg.style.display = 'none';
|
||||
|
||||
// Start polling for QR; the first fetchQR may not have QR ready yet
|
||||
startStatusPolling();
|
||||
startQRPolling();
|
||||
}).catch(function () {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Conectar WhatsApp';
|
||||
@@ -151,7 +165,10 @@
|
||||
}
|
||||
|
||||
function fetchQR() {
|
||||
qrPlaceholder.textContent = 'Generando QR...';
|
||||
// Only update placeholder text if we don't already have a QR image showing
|
||||
if (qrImg.style.display !== 'block') {
|
||||
qrPlaceholder.textContent = 'Generando codigo QR, espera unos segundos...';
|
||||
}
|
||||
|
||||
api('GET', '/qr').then(function (data) {
|
||||
var base64 = data.qr || data.base64 || data.qrcode || '';
|
||||
@@ -164,15 +181,19 @@
|
||||
|
||||
// Start polling for connection state while QR is shown
|
||||
startStatusPolling();
|
||||
startQRPolling();
|
||||
} else if ((data.instance && data.instance.state === 'open') || data.state === 'open') {
|
||||
// Already connected
|
||||
updateConnectionUI('open');
|
||||
loadConversations();
|
||||
} else {
|
||||
qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.';
|
||||
// QR not ready yet — this is normal right after pressing Connect
|
||||
if (qrImg.style.display !== 'block') {
|
||||
qrPlaceholder.textContent = 'Generando codigo QR, por favor espera... (el codigo cambia cada pocos segundos, escanealo en cuanto aparezca)';
|
||||
qrPlaceholder.style.display = '';
|
||||
qrImg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}).catch(function () {
|
||||
qrPlaceholder.textContent = 'Error al obtener QR';
|
||||
});
|
||||
@@ -208,6 +229,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startQRPolling() {
|
||||
stopQRPolling();
|
||||
qrPollTimer = setInterval(function () {
|
||||
if (connectionState === 'connecting' || connectionState === 'qr') {
|
||||
fetchQR();
|
||||
} else {
|
||||
stopQRPolling();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopQRPolling() {
|
||||
if (qrPollTimer) {
|
||||
clearInterval(qrPollTimer);
|
||||
qrPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener('click', doConnect);
|
||||
disconnectBtn.addEventListener('click', doDisconnect);
|
||||
refreshQrBtn.addEventListener('click', fetchQR);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// /home/Autopartes/pos/static/pwa/sw.js
|
||||
// Nexus POS — Service Worker v6
|
||||
// Nexus POS — Service Worker v9
|
||||
// Self-contained vanilla JS. No external imports.
|
||||
|
||||
const CACHE_NAME = 'nexus-pos-v6';
|
||||
const CACHE_NAME = 'nexus-pos-v11';
|
||||
|
||||
const APP_SHELL = [
|
||||
'/pos/static/css/tokens.css',
|
||||
|
||||
127
pos/tasks.py
127
pos/tasks.py
@@ -98,6 +98,133 @@ def generate_report_task(self, report_type, params, tenant_id):
|
||||
}
|
||||
|
||||
|
||||
# ─── MercadoLibre Tasks ───────────────────────────────────────────────────
|
||||
|
||||
@celery.task(bind=True, max_retries=3)
|
||||
def sync_meli_stock_price_task(self, tenant_id: int):
|
||||
"""Sync stock and price for all active ML listings."""
|
||||
from services import marketplace_external_service as meli_svc
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return {'error': 'MercadoLibre not configured'}
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, inventory_id, external_item_id, publish_price
|
||||
FROM marketplace_listings
|
||||
WHERE is_active = true AND channel = 'mercadolibre'
|
||||
"""
|
||||
)
|
||||
listings = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
from services.inventory_engine import get_stock_bulk
|
||||
stock_map = get_stock_bulk(conn, branch_id=None)
|
||||
|
||||
updated = 0
|
||||
failed = 0
|
||||
for row in listings:
|
||||
listing_id, inv_id, ext_id, last_price = row
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT price_1 FROM inventory WHERE id = %s", (inv_id,))
|
||||
price_row = cur.fetchone()
|
||||
cur.close()
|
||||
current_price = float(price_row[0]) if price_row and price_row[0] else 0
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
|
||||
try:
|
||||
svc.update_item(
|
||||
ext_id,
|
||||
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE marketplace_listings
|
||||
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(current_price, listing_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
|
||||
(str(e)[:500], listing_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
failed += 1
|
||||
|
||||
return {'updated': updated, 'failed': failed, 'total': len(listings)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@celery.task(bind=True, max_retries=3)
|
||||
def sync_meli_orders_task(self, tenant_id: int):
|
||||
"""Fetch new orders from MercadoLibre."""
|
||||
from services import marketplace_external_service as meli_svc
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
# Determine last check date from most recent order
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT MAX(created_at) FROM marketplace_orders WHERE channel = 'mercadolibre'"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
last_check = row[0]
|
||||
cur.close()
|
||||
|
||||
date_from = None
|
||||
if last_check:
|
||||
date_from = last_check.strftime('%Y-%m-%dT%H:%M:%S.000-00:00')
|
||||
|
||||
result = meli_svc.fetch_and_save_orders(conn, date_from=date_from)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@celery.task(bind=True, max_retries=3)
|
||||
def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
|
||||
"""Process incoming MercadoLibre webhook asynchronously."""
|
||||
from services import marketplace_external_service as meli_svc
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
if topic.startswith("orders"):
|
||||
# Fetch full order and upsert
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if svc and resource:
|
||||
order_id = resource.split("/")[-1]
|
||||
full = svc.get_order(order_id)
|
||||
# Re-use fetch_and_save_orders by passing the order directly
|
||||
# For simplicity, trigger a full sync for recent orders
|
||||
return meli_svc.fetch_and_save_orders(conn)
|
||||
return {'ok': True, 'topic': topic}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@celery.task(bind=True, max_retries=2)
|
||||
def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, brand, compat_source):
|
||||
"""Fetch AI/TecDoc vehicle compatibility in background after item creation."""
|
||||
|
||||
@@ -188,10 +188,10 @@
|
||||
<!-- Tabs -->
|
||||
<div class="tabs-row" role="tablist">
|
||||
<button class="tab-btn is-active" role="tab" aria-selected="true" onclick="switchTab('cxc')">
|
||||
Ctas. por Cobrar <span class="tab-btn__badge">23</span>
|
||||
Ctas. por Cobrar <span class="tab-btn__badge" id="badge-cxc">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('cxp')">
|
||||
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge">3</span>
|
||||
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge" id="badge-cxp">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('balance')">
|
||||
Balance General
|
||||
@@ -498,5 +498,31 @@
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
<script>
|
||||
// Load accounting stats for tab badges
|
||||
async function loadAccountingStats() {
|
||||
const token = localStorage.getItem('pos_token') || '';
|
||||
try {
|
||||
const res = await fetch('/pos/api/accounting/stats', {
|
||||
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const map = {
|
||||
'badge-cxc': data.cuentas_cobrar,
|
||||
'badge-cxp': data.cuentas_pagar
|
||||
};
|
||||
Object.entries(map).forEach(function([id, val]) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val || 0;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load accounting stats:', e);
|
||||
}
|
||||
}
|
||||
window.loadAccountingStats = loadAccountingStats;
|
||||
loadAccountingStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -420,6 +420,10 @@
|
||||
<div class="credit-metric__label">Utilizado</div>
|
||||
<div class="credit-metric__value used" id="detailCreditUsed">$31,500</div>
|
||||
</div>
|
||||
<div class="credit-metric">
|
||||
<div class="credit-metric__label">Descuento Max</div>
|
||||
<div class="credit-metric__value" id="detailMaxDiscount">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credit-progress">
|
||||
<div class="credit-progress__labels">
|
||||
@@ -611,7 +615,7 @@
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn-edit" onclick="if(typeof Customers!=='undefined') Customers.editCurrent();">Editar Cliente</button>
|
||||
<button class="btn-sale" onclick="window.location.href='/pos/';">Nueva Venta</button>
|
||||
<button class="btn-sale" onclick="window.location.href='/pos/sale';">Nueva Venta</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css">
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=4">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -306,10 +306,18 @@
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
<button class="btn btn--ghost btn--sm" onclick="showTierDiscountModal()">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
||||
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
||||
</button>
|
||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
<button class="btn btn--sm btn--meli" id="btnPublishML" style="display:none;" onclick="openMeliPublishModal()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
Publicar en ML <span id="meliSelectedCountBadge" style="background:#2D3277;color:#FFE600;border-radius:10px;padding:0 6px;font-size:11px;margin-left:4px;">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
@@ -317,6 +325,7 @@
|
||||
<table class="data-table" id="stockTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px;"><input type="checkbox" id="selectAllItems" onclick="toggleSelectAllItems()" title="Seleccionar todos" /></th>
|
||||
<th style="font-size:var(--text-caption);color:var(--color-text-muted);">ID</th>
|
||||
<th>Barcode</th>
|
||||
<th>No. Parte</th>
|
||||
@@ -324,9 +333,9 @@
|
||||
<th>Marca</th>
|
||||
<th style="text-align:right">Stock</th>
|
||||
<th style="text-align:right">Costo</th>
|
||||
<th style="text-align:right">Precio 1</th>
|
||||
<th style="text-align:right">Precio 2</th>
|
||||
<th style="text-align:right">Precio 3</th>
|
||||
<th style="text-align:right">Mostrador</th>
|
||||
<th style="text-align:right">Taller</th>
|
||||
<th style="text-align:right">Mayoreo</th>
|
||||
<th>Ubicación</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
@@ -689,9 +698,7 @@
|
||||
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
|
||||
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
|
||||
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
|
||||
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
|
||||
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
|
||||
@@ -809,6 +816,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier Discounts Modal -->
|
||||
<div class="inv-modal-overlay" id="tierDiscountModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Descuentos por Tipo de Cliente</h3>
|
||||
<button class="inv-modal__close" onclick="closeTierDiscountModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<p style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-4);">Estos descuentos se aplican automáticamente a todos los productos al calcular precios de Taller y Mayoreo.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div class="inv-field"><label>Descuento Taller (%)</label><input type="number" id="tierDisc2" step="0.1" min="0" max="100" placeholder="15" /></div>
|
||||
<div class="inv-field"><label>Descuento Mayoreo (%)</label><input type="number" id="tierDisc3" step="0.1" min="0" max="100" placeholder="25" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeTierDiscountModal()">Cancelar</button>
|
||||
<button class="btn btn--primary" onclick="saveTierDiscounts()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ Publicar en MercadoLibre Modal ══════════ -->
|
||||
<div class="inv-modal-overlay" id="meliPublishModal">
|
||||
<div class="inv-modal inv-modal--wide">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Publicar en MercadoLibre</h3>
|
||||
<button class="inv-modal__close" onclick="closeMeliPublishModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div id="meliPublishSelectedCount" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-3);">0 productos seleccionados</div>
|
||||
<div id="meliPublishItemsPreview" style="max-height:200px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-4);">
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Selecciona productos del inventario para ver el preview.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:var(--space-4);">
|
||||
<div class="inv-field">
|
||||
<label>Categoría ML *</label>
|
||||
<div style="position:relative;">
|
||||
<input type="text" id="meliCategorySearch" placeholder="Buscar categoría..." oninput="searchMeliCategories()" onkeydown="handleMeliCatKeydown(event)" autocomplete="off" />
|
||||
<div id="meliCategoryResults"></div>
|
||||
</div>
|
||||
<input type="hidden" id="meliCategoryId" />
|
||||
</div>
|
||||
<div class="inv-field">
|
||||
<label>Tipo de Publicación</label>
|
||||
<select id="meliListingType">
|
||||
<option value="gold_special">Gold Special</option>
|
||||
<option value="gold_pro">Gold Pro</option>
|
||||
<option value="bronze">Bronce (gratis)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inv-field">
|
||||
<label>Modo de Envío</label>
|
||||
<select id="meliShippingMode">
|
||||
<option value="me2" selected>MercadoEnvíos (me2)</option>
|
||||
</select>
|
||||
<small style="color:var(--color-text-muted);font-size:var(--text-caption);">Tu cuenta requiere ME2 obligatoriamente.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="meliPublishResult" style="min-height:1.5em;"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeMeliPublishModal()">Cancelar</button>
|
||||
<button class="btn btn--meli" id="meliPublishBtn" onclick="executeMeliPublish()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Publicar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline Banner -->
|
||||
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
|
||||
<span class="banner__icon"></span>
|
||||
@@ -821,7 +895,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=10" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
|
||||
@@ -301,16 +301,16 @@
|
||||
<!-- Tabs Row -->
|
||||
<div class="tabs-row" role="tablist" aria-label="Módulos de Facturación">
|
||||
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-facturas" id="tab-facturas" onclick="switchTab('facturas')">
|
||||
Facturas <span class="tab-btn__badge">247</span>
|
||||
Facturas <span class="tab-btn__badge" id="badge-facturas">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-notas" id="tab-notas" onclick="switchTab('notas')">
|
||||
Notas de Crédito <span class="tab-btn__badge">8</span>
|
||||
Notas de Crédito <span class="tab-btn__badge" id="badge-notas-credito">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-complementos" id="tab-complementos" onclick="switchTab('complementos')">
|
||||
Complementos de Pago <span class="tab-btn__badge">12</span>
|
||||
Complementos de Pago <span class="tab-btn__badge" id="badge-complementos">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-cancelaciones" id="tab-cancelaciones" onclick="switchTab('cancelaciones')">
|
||||
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn">6</span>
|
||||
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn" id="badge-cancelaciones">0</span>
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-config" id="tab-config" onclick="switchTab('config')">
|
||||
Configuración CFDI
|
||||
@@ -1060,5 +1060,33 @@
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
<script>
|
||||
// Load invoicing stats for tab badges
|
||||
async function loadInvoicingStats() {
|
||||
const token = localStorage.getItem('pos_token') || '';
|
||||
try {
|
||||
const res = await fetch('/pos/api/invoicing/stats', {
|
||||
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const map = {
|
||||
'badge-facturas': data.facturas,
|
||||
'badge-notas-credito': data.notas_credito,
|
||||
'badge-complementos': data.complementos,
|
||||
'badge-cancelaciones': data.cancelaciones
|
||||
};
|
||||
Object.entries(map).forEach(function([id, val]) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val || 0;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load invoicing stats:', e);
|
||||
}
|
||||
}
|
||||
window.loadInvoicingStats = loadInvoicingStats;
|
||||
loadInvoicingStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -291,6 +291,13 @@
|
||||
btnLogin.disabled = !canLogin;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
LOGIN BUTTON CLICK
|
||||
------------------------------------------------------------------ */
|
||||
btnLogin.addEventListener('click', function() {
|
||||
triggerLogin();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
TRIGGER LOGIN (demo)
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@@ -57,10 +57,11 @@
|
||||
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Mi Inventario</h2>
|
||||
<div class="upload-box">
|
||||
<label>Cargar inventario via CSV</label>
|
||||
<textarea id="csvText" placeholder="part_number,stock,price AB-123,5,150.50 CD-456,12,89.00"></textarea>
|
||||
<textarea id="csvText" placeholder="part_number,stock,price,name AB-123,5,150.50,Filtro de aceite CD-456,12,89.00,Balata delantera"></textarea>
|
||||
<div class="hint">
|
||||
Columnas requeridas: <code>part_number, stock, price</code>.
|
||||
Opcionales: <code>min_order, warehouse_location, currency</code>.
|
||||
Opcionales: <code>name, min_order, warehouse_location, currency</code>.
|
||||
<br>Si la parte no existe en el catálogo, se crea automáticamente como <em>seller listing</em>.
|
||||
</div>
|
||||
<button style="margin-top:var(--space-3);padding:var(--space-3) var(--space-6);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-md);font-weight:bold;cursor:pointer;" onclick="uploadCSV()">Subir CSV</button>
|
||||
<div id="uploadResult" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
|
||||
@@ -205,8 +206,18 @@
|
||||
var priceStr = p.min_price === p.max_price
|
||||
? '$' + fmt(p.min_price)
|
||||
: '$' + fmt(p.min_price) + ' – $' + fmt(p.max_price);
|
||||
return '<div class="part-card" onclick="openPartDetail(' + p.id_part + ')">' +
|
||||
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
|
||||
var isOem = p.listing_type === 'oem';
|
||||
var badge = isOem
|
||||
? '<span style="background:#3FB95020;color:#3FB950;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Catálogo</span>'
|
||||
: '<span style="background:#F5A62320;color:#F5A623;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Listing</span>';
|
||||
var onclick = isOem
|
||||
? 'openPartDetail(' + p.id + ')'
|
||||
: 'openListingDetail(' + p.id + ')';
|
||||
return '<div class="part-card" onclick="' + onclick + '">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-1);">' +
|
||||
'<div class="part-card__oem">' + esc(p.part_number) + '</div>' +
|
||||
badge +
|
||||
'</div>' +
|
||||
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||||
'<div class="part-card__meta">' +
|
||||
'<span class="price-range">' + priceStr + '</span>' +
|
||||
@@ -248,7 +259,7 @@
|
||||
'</div>' +
|
||||
'<div style="text-align:right;">' +
|
||||
'<div class="price-range">$' + fmt(b.price) + '</div>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', null, ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
@@ -257,7 +268,38 @@
|
||||
});
|
||||
};
|
||||
|
||||
window.createOrderFor = function (partId, bodegaId, bodegaName, price) {
|
||||
window.openListingDetail = function (wiId) {
|
||||
var modal = document.getElementById('partModal');
|
||||
modal.classList.add('open');
|
||||
document.getElementById('partModalBody').innerHTML = 'Cargando bodegas...';
|
||||
|
||||
apiFetch('/inventory/listing/' + wiId).then(function (resp) {
|
||||
if (!resp || !resp.data) return;
|
||||
var bodegas = resp.data;
|
||||
if (bodegas.length === 0) {
|
||||
document.getElementById('partModalBody').innerHTML = '<div class="empty-state">Ninguna bodega tiene esta parte en stock.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<p style="color:var(--color-text-muted);margin-bottom:var(--space-3);">Elige una bodega para ordenar:</p>';
|
||||
html += '<div style="display:flex;flex-direction:column;gap:var(--space-2);">';
|
||||
bodegas.forEach(function (b) {
|
||||
html += '<div style="padding:var(--space-3);border:1px solid var(--glass-border);border-radius:var(--radius-md);display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<div>' +
|
||||
'<strong>' + esc(b.name) + '</strong>' +
|
||||
'<div style="color:var(--color-text-muted);font-size:var(--text-caption);">' + esc(b.city || '') + ' · ' + esc(b.stock_hint) + '</div>' +
|
||||
'</div>' +
|
||||
'<div style="text-align:right;">' +
|
||||
'<div class="price-range">$' + fmt(b.price) + '</div>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(null, ' + wiId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
document.getElementById('partModalBody').innerHTML = html;
|
||||
});
|
||||
};
|
||||
|
||||
window.createOrderFor = function (partId, wiId, bodegaId, bodegaName, price) {
|
||||
var qty = prompt('Cantidad a ordenar para "' + bodegaName + '":', '1');
|
||||
if (!qty) return;
|
||||
qty = parseInt(qty);
|
||||
@@ -265,12 +307,22 @@
|
||||
|
||||
var notes = prompt('Notas para la bodega (opcional):', '') || '';
|
||||
|
||||
var item = { quantity: qty, unit_price: price };
|
||||
if (partId) {
|
||||
item.part_id = partId;
|
||||
} else if (wiId) {
|
||||
item.wi_id = wiId;
|
||||
} else {
|
||||
alert('Error: se requiere part_id o wi_id');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create PO draft
|
||||
apiFetch('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bodega_id: bodegaId,
|
||||
items: [{ part_id: partId, quantity: qty, unit_price: price }],
|
||||
items: [item],
|
||||
delivery_method: 'pickup',
|
||||
buyer_notes: notes,
|
||||
}),
|
||||
@@ -482,6 +534,10 @@
|
||||
r.updated + ' actualizados';
|
||||
if (r.skipped > 0) msg += ', ' + r.skipped + ' omitidos';
|
||||
msg += '</span>';
|
||||
if (r.oem_count !== undefined || r.seller_count !== undefined) {
|
||||
msg += '<div style="margin-top:var(--space-2);font-size:var(--text-caption);color:var(--color-text-muted);">' +
|
||||
(r.oem_count || 0) + ' catálogo OEM · ' + (r.seller_count || 0) + ' seller listings</div>';
|
||||
}
|
||||
if (r.errors && r.errors.length) {
|
||||
msg += '<div style="margin-top:var(--space-2);color:var(--color-text-muted);font-size:var(--text-caption);">';
|
||||
r.errors.slice(0, 5).forEach(function (e) {
|
||||
|
||||
322
pos/templates/marketplace_external.html
Normal file
322
pos/templates/marketplace_external.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" data-theme="industrial">
|
||||
<head>
|
||||
<script>(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MercadoLibre — Nexus Autoparts POS</title>
|
||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css" />
|
||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
<style>
|
||||
.meli-status { display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:600; }
|
||||
.meli-status--active { background:#d4edda;color:#155724; }
|
||||
.meli-status--paused { background:#fff3cd;color:#856404; }
|
||||
.meli-status--closed { background:#f8d7da;color:#721c24; }
|
||||
.meli-status--pending { background:#e2e3e5;color:#383d41; }
|
||||
.meli-card { background:var(--color-surface-1);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-4); }
|
||||
.meli-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:var(--space-4); }
|
||||
.meli-connect-btn { display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:#FFE600;color:#2D3277;border:none;border-radius:var(--radius-md);font-weight:700;cursor:pointer; }
|
||||
.meli-connect-btn:hover { filter:brightness(0.95); }
|
||||
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
|
||||
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
|
||||
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- =========================================================================
|
||||
THEME SWITCHER BAR
|
||||
========================================================================= -->
|
||||
|
||||
<header class="theme-bar" role="banner">
|
||||
<div class="theme-bar__left">
|
||||
<div class="theme-bar__store">
|
||||
<span class="theme-bar__dot"></span>
|
||||
Nexus Autoparts
|
||||
</div>
|
||||
<div class="theme-bar__sep"></div>
|
||||
<span class="theme-bar__label">MercadoLibre — Integración</span>
|
||||
</div>
|
||||
<div class="theme-bar__right">
|
||||
<span class="theme-bar__label">Tema:</span>
|
||||
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
|
||||
<span class="theme-btn__swatch"></span>
|
||||
Industrial
|
||||
</button>
|
||||
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
|
||||
<span class="theme-btn__swatch"></span>
|
||||
Moderno
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =========================================================================
|
||||
APP SHELL
|
||||
========================================================================= -->
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
<!-- -----------------------------------------------------------------------
|
||||
SIDEBAR NAVIGATION
|
||||
----------------------------------------------------------------------- -->
|
||||
|
||||
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
|
||||
<div class="sidebar__brand">
|
||||
<div class="brand-logo">NA</div>
|
||||
<div class="brand-name">
|
||||
<span class="brand-name__primary">Nexus</span>
|
||||
<span class="brand-name__sub">Autoparts POS</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar__nav">
|
||||
<div class="nav-section-label">Principal</div>
|
||||
<a class="nav-item" href="/pos/dashboard">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/sale">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||
</svg>
|
||||
<span>POS</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/catalog">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
<span>Catálogo</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/inventory">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<span>Inventario</span>
|
||||
</a>
|
||||
<div class="nav-section-label">Gestión</div>
|
||||
<a class="nav-item" href="/pos/customers">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<span>Clientes</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/marketplace">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
||||
</svg>
|
||||
<span>Marketplace B2B</span>
|
||||
</a>
|
||||
<a class="nav-item is-active" href="/pos/marketplace-external" aria-current="page">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||
</svg>
|
||||
<span>MercadoLibre</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/invoicing">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>Facturación</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/config">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
<span>Configuración</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar__footer">
|
||||
<div class="sidebar__user-avatar" id="sidebarAvatar">U</div>
|
||||
<div class="sidebar__user-info">
|
||||
<div class="sidebar__user-name" id="sidebarName">Usuario</div>
|
||||
<div class="sidebar__user-role" id="sidebarRole">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- -----------------------------------------------------------------------
|
||||
MAIN CONTENT
|
||||
----------------------------------------------------------------------- -->
|
||||
|
||||
<main class="main" role="main">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header__title-group">
|
||||
<span class="page-header__eyebrow">Marketplace</span>
|
||||
<h1 class="page-header__title">MercadoLibre</h1>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<a class="btn btn--ghost" href="/pos/dashboard">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-config" onclick="switchTab('config')">
|
||||
Configuración
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-listings" onclick="switchTab('listings')">
|
||||
Publicaciones
|
||||
</button>
|
||||
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
|
||||
Órdenes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div class="tab-panels" id="tab-panels">
|
||||
|
||||
<!-- ══════════ TAB: Configuración ══════════ -->
|
||||
<div class="tab-panel is-active" id="panel-config" role="tabpanel">
|
||||
<div class="meli-card" style="max-width:600px;">
|
||||
<h3 style="margin:0 0 var(--space-4);font-family:var(--font-heading);">Conexión con MercadoLibre</h3>
|
||||
<div id="configStatus">
|
||||
<p>Cargando estado...</p>
|
||||
</div>
|
||||
<div id="configForm" style="display:none;margin-top:var(--space-4);">
|
||||
<p style="margin-bottom:var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-secondary);">
|
||||
Para conectar, necesitas una <strong>aplicación de MercadoLibre</strong>.
|
||||
Ve a <a href="https://developers.mercadolibre.com.mx" target="_blank">developers.mercadolibre.com.mx</a>
|
||||
y crea una app. Luego pega los datos aquí:
|
||||
</p>
|
||||
<div class="meli-config-row">
|
||||
<div>
|
||||
<label>Client ID</label>
|
||||
<input type="text" id="cfgClientId" placeholder="Tu App ID" style="width:200px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Client Secret</label>
|
||||
<input type="password" id="cfgClientSecret" placeholder="Tu Secret Key" style="width:200px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="meli-config-row">
|
||||
<div>
|
||||
<label>Categoría Default</label>
|
||||
<input type="text" id="cfgCategory" placeholder="MLM1747" style="width:150px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Modo de Envío</label>
|
||||
<select id="cfgShipping">
|
||||
<option value="me2">MercadoEnvíos (me2)</option>
|
||||
<option value="custom">Propio (custom)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="meli-connect-btn" onclick="startOAuth()">🔗 Conectar con MercadoLibre</button>
|
||||
</div>
|
||||
<div id="configConnected" style="display:none;margin-top:var(--space-4);">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:var(--space-3);">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:#FFE600;display:flex;align-items:center;justify-content:center;font-weight:800;color:#2D3277;">ML</div>
|
||||
<div>
|
||||
<div style="font-weight:700;" id="connectedNickname">Usuario ML</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);" id="connectedSite">MLM</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--danger btn--sm" onclick="disconnectMeli()">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ TAB: Publicaciones ══════════ -->
|
||||
<div class="tab-panel" id="panel-listings" role="tabpanel">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="listingSearch" placeholder="Buscar publicación..." oninput="filterListings()" />
|
||||
</div>
|
||||
<select class="select-filter" id="listingStatusFilter" onchange="filterListings()">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="active">Activas</option>
|
||||
<option value="paused">Pausadas</option>
|
||||
<option value="closed">Cerradas</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary" onclick="loadListings()">🔄 Actualizar</button>
|
||||
</div>
|
||||
<div id="listingsContainer" class="meli-grid"></div>
|
||||
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ TAB: Órdenes ══════════ -->
|
||||
<div class="tab-panel" id="panel-orders" role="tabpanel">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="orderSearch" placeholder="Buscar orden..." oninput="filterOrders()" />
|
||||
</div>
|
||||
<select class="select-filter" id="orderStatusFilter" onchange="filterOrders()">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="pending">Pendientes</option>
|
||||
<option value="confirmed">Confirmadas</option>
|
||||
<option value="packed">Empacadas</option>
|
||||
<option value="shipped">Enviadas</option>
|
||||
<option value="delivered">Entregadas</option>
|
||||
<option value="cancelled">Canceladas</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary" onclick="loadOrders()">🔄 Actualizar</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Orden ML</th>
|
||||
<th>Comprador</th>
|
||||
<th style="text-align:right">Total</th>
|
||||
<th>Estado Nexus</th>
|
||||
<th>Fecha</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersTableBody"></tbody>
|
||||
</table>
|
||||
<div id="ordersPagination" class="table-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /tab-panels -->
|
||||
|
||||
</main>
|
||||
|
||||
</div><!-- /app-shell -->
|
||||
|
||||
<!-- ══════════ Order Detail Modal ══════════ -->
|
||||
<div class="inv-modal-overlay" id="orderModal">
|
||||
<div class="inv-modal inv-modal--wide">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Detalle de Orden ML</h3>
|
||||
<button class="inv-modal__close" onclick="closeModal('orderModal')">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body" id="orderModalBody">cargando...</div>
|
||||
<div class="inv-modal__footer" id="orderModalFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js" defer></script>
|
||||
<script src="/pos/static/js/app-init.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
<script src="/pos/static/js/marketplace_external.js?v=3" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,6 +32,10 @@
|
||||
<span id="statusClock"></span>
|
||||
</div>
|
||||
<div class="status-bar__right">
|
||||
<a href="/pos/dashboard" class="btn btn--ghost btn--sm" id="backToSystemBtn" style="margin-right:8px;display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
Sistema
|
||||
</a>
|
||||
<div class="status-bar__user" aria-label="Usuario activo">
|
||||
<div class="status-bar__user-avatar" aria-hidden="true">--</div>
|
||||
<span id="employeeName">Empleado</span>
|
||||
@@ -203,7 +207,7 @@
|
||||
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
||||
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
||||
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
|
||||
<button class="btn-secondary-action danger" id="btnCancelSale" title="Cancelar (Esc)">Cancelar</button>
|
||||
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -233,14 +237,14 @@
|
||||
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
|
||||
</div>
|
||||
<div class="fkey-sep"></div>
|
||||
<div class="fkey" title="Cantidad +/-">
|
||||
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
|
||||
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
|
||||
</div>
|
||||
<div class="fkey" title="Descuento">
|
||||
<div class="fkey" onclick="POS.applyDiscount()" title="Descuento">
|
||||
<span class="fkey-key">*</span><span class="fkey-label">Descuento</span>
|
||||
</div>
|
||||
<div class="fkey-sep"></div>
|
||||
<div class="fkey" id="fkeyEsc" title="Cancelar">
|
||||
<div class="fkey" id="fkeyEsc" onclick="POS.openCancelModal()" title="Cancelar">
|
||||
<span class="fkey-key">Esc</span><span class="fkey-label">Cancelar</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,7 +567,7 @@
|
||||
<script src="/pos/static/js/app-init.js" defer></script>
|
||||
<script src="/pos/static/js/push.js" defer></script>
|
||||
<script src="/pos/static/js/printer.js" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=4" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=5" defer></script>
|
||||
|
||||
<script>
|
||||
// Cancel sale button wiring
|
||||
@@ -577,11 +581,7 @@
|
||||
closeCancelModal();
|
||||
// Clear cart via POS module
|
||||
if (typeof POS !== 'undefined') {
|
||||
// Clear each item
|
||||
const tbody = document.getElementById('cartBody');
|
||||
while (tbody && tbody.firstChild) {
|
||||
POS.removeFromCart(0);
|
||||
}
|
||||
POS.clearCart();
|
||||
POS.clearCustomer();
|
||||
}
|
||||
});
|
||||
@@ -614,6 +614,20 @@
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 30000);
|
||||
|
||||
// Show "Back to System" button when NOT in kiosk mode
|
||||
(function checkKiosk() {
|
||||
var btn = document.getElementById('backToSystemBtn');
|
||||
if (!btn) return;
|
||||
function showIfNotKiosk() {
|
||||
try {
|
||||
var isKiosk = window.isKioskEnabled && window.isKioskEnabled();
|
||||
btn.style.display = isKiosk ? 'none' : 'inline-flex';
|
||||
} catch (e) { btn.style.display = 'inline-flex'; }
|
||||
}
|
||||
showIfNotKiosk();
|
||||
setInterval(showIfNotKiosk, 2000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
|
||||
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
|
||||
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
|
||||
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
|
||||
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:var(--color-bg-overlay);border:1.5px solid var(--color-border);color:var(--color-text-muted);cursor:pointer;font-size:13px;padding:6px 10px;border-radius:var(--radius-md);display:inline-flex;align-items:center;gap:4px;transition:var(--transition-fast);" onmouseover="this.style.color=\'var(--color-error)\';this.style.borderColor=\'var(--color-error)\';this.style.background=\'rgba(248,81,73,0.08)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.borderColor=\'var(--color-border)\';this.style.background=\'var(--color-bg-overlay)\'">';
|
||||
html += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
html += 'Eliminar</button></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
@@ -114,15 +116,33 @@
|
||||
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;flex-wrap:wrap;">';
|
||||
html += '<div style="margin-top:var(--space-5);">';
|
||||
// Primary actions
|
||||
if (q.status === 'active') {
|
||||
html += '<button class="btn btn--ghost" onclick="editQuote(' + q.id + ')" style="color:#4f46e5;">Editar</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="convertQuote(' + q.id + ')" style="color:#059669;">Convertir a venta</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')">Compartir link</button>';
|
||||
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3);">';
|
||||
html += '<button class="btn btn--primary" onclick="convertQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
|
||||
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>';
|
||||
html += 'Convertir a Venta</button>';
|
||||
html += '<button class="btn btn--secondary" onclick="editQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
|
||||
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||
html += 'Editar</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;border:1.5px solid var(--color-border);">';
|
||||
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
|
||||
html += 'Compartir</button>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
|
||||
// Secondary actions
|
||||
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;justify-content:flex-end;">';
|
||||
html += '<button class="btn btn--ghost" onclick="window.print()" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
|
||||
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>';
|
||||
html += 'Imprimir</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
|
||||
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
html += 'Exportar CSV</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;color:var(--color-error);">';
|
||||
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
html += 'Eliminar</button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('quoteDetail').innerHTML = html;
|
||||
@@ -142,10 +162,12 @@
|
||||
} else {
|
||||
alert('Error: ' + (d.error || 'desconocido'));
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function(err) { alert('Error de red al eliminar: ' + err.message); });
|
||||
};
|
||||
|
||||
window.editQuote = function(id) {
|
||||
try {
|
||||
fetch(API + '/quotations/' + id, { headers: headers() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(q) {
|
||||
@@ -165,11 +187,14 @@
|
||||
localStorage.setItem('pos_edit_quote_customer_id', q.customer_id || '');
|
||||
localStorage.setItem('pos_edit_quote_notes', q.notes || '');
|
||||
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
|
||||
window.location.href = '/pos';
|
||||
});
|
||||
window.location.href = '/pos/sale';
|
||||
})
|
||||
.catch(function(err) { alert('Error al cargar para editar: ' + err.message); });
|
||||
} catch (e) { alert('Excepcion en editQuote: ' + e.message); }
|
||||
};
|
||||
|
||||
window.convertQuote = function(id) {
|
||||
try {
|
||||
fetch(API + '/quotations/' + id, { headers: headers() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(q) {
|
||||
@@ -187,24 +212,33 @@
|
||||
});
|
||||
localStorage.setItem('pos_convert_quote_id', id);
|
||||
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
|
||||
window.location.href = '/pos';
|
||||
});
|
||||
window.location.href = '/pos/sale';
|
||||
})
|
||||
.catch(function(err) { alert('Error al cargar para convertir: ' + err.message); });
|
||||
} catch (e) { alert('Excepcion en convertQuote: ' + e.message); }
|
||||
};
|
||||
|
||||
window.shareQuote = function(id) {
|
||||
try {
|
||||
fetch(API + '/quotations/' + id + '/share', { method: 'POST', headers: headers() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.url) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(d.url).then(function() {
|
||||
alert('Link copiado al portapapeles:\n' + d.url);
|
||||
}).catch(function() {
|
||||
prompt('Copia este link:', d.url);
|
||||
});
|
||||
} else {
|
||||
alert('Error: ' + (d.error || 'desconocido'));
|
||||
prompt('Copia este link:', d.url);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('Error del servidor: ' + (d.error || 'desconocido'));
|
||||
}
|
||||
})
|
||||
.catch(function(err) { alert('Error de red al compartir: ' + err.message); });
|
||||
} catch (e) { alert('Excepcion en shareQuote: ' + e.message); }
|
||||
};
|
||||
|
||||
// Close modal on outside click
|
||||
|
||||
@@ -131,7 +131,7 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
||||
|
||||
<!-- Sidebar -->
|
||||
<script src="/pos/static/js/i18n.js" defer></script>
|
||||
<script src="/pos/static/js/whatsapp2.js" defer></script>
|
||||
<script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
|
||||
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } = require('@whiskeysockets/baileys');
|
||||
const express = require('express');
|
||||
const QRCode = require('qrcode');
|
||||
const pino = require('pino');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -17,15 +19,117 @@ const AUTH_DIR = process.env.AUTH_DIR || '/app/auth';
|
||||
let sock = null;
|
||||
let qrCode = null;
|
||||
let connectionState = 'disconnected';
|
||||
let retry440Count = 0;
|
||||
let lastConnectAttempt = 0;
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'warn' });
|
||||
|
||||
// Queue for outgoing messages when disconnected
|
||||
const sendQueue = [];
|
||||
let queueTimer = null;
|
||||
let connectWatchdog = null;
|
||||
let staleWatchdog = null;
|
||||
const WATCHDOG_MS = 90000;
|
||||
const STALE_MS = 90000;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
function updateActivity() {
|
||||
lastActivity = Date.now();
|
||||
}
|
||||
|
||||
function clearAuthState() {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_DIR)) {
|
||||
fs.readdirSync(AUTH_DIR).forEach(f => {
|
||||
try { fs.unlinkSync(path.join(AUTH_DIR, f)); } catch (e) {}
|
||||
});
|
||||
}
|
||||
console.log(`[Tenant ${TENANT_ID}] Auth state cleared`);
|
||||
} catch (e) {
|
||||
console.error(`[Tenant ${TENANT_ID}] Failed to clear auth:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function flushSendQueue() {
|
||||
if (!sock || connectionState !== 'open') return;
|
||||
while (sendQueue.length > 0) {
|
||||
const item = sendQueue.shift();
|
||||
try {
|
||||
sock.sendMessage(item.jid, { text: item.message });
|
||||
console.log(`[Tenant ${TENANT_ID}] Flushed queued message to ${item.jid}`);
|
||||
} catch (e) {
|
||||
console.error(`[Tenant ${TENANT_ID}] Failed to flush queued message:`, e.message);
|
||||
sendQueue.unshift(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearWatchdog() {
|
||||
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
|
||||
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
|
||||
}
|
||||
|
||||
function scheduleStaleWatchdog() {
|
||||
if (staleWatchdog) clearInterval(staleWatchdog);
|
||||
staleWatchdog = setInterval(() => {
|
||||
if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`);
|
||||
try { sock?.ws?.close(); } catch (e) {}
|
||||
sock = null;
|
||||
connectionState = 'disconnected';
|
||||
setTimeout(connectWhatsApp, 30000);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function scheduleWatchdog() {
|
||||
clearWatchdog();
|
||||
connectWatchdog = setTimeout(() => {
|
||||
if (connectionState !== 'open') {
|
||||
console.log(`[Tenant ${TENANT_ID}] Watchdog: connection not stable after ${WATCHDOG_MS/1000}s, forcing reconnect`);
|
||||
try { sock?.ws?.close(); } catch (e) {}
|
||||
sock = null;
|
||||
connectionState = 'disconnected';
|
||||
setTimeout(connectWhatsApp, 30000);
|
||||
}
|
||||
}, WATCHDOG_MS);
|
||||
}
|
||||
|
||||
async function connectWhatsApp() {
|
||||
// Rate limit: max 1 attempt per 30 seconds to avoid WhatsApp throttling
|
||||
const now = Date.now();
|
||||
if (now - lastConnectAttempt < 30000) {
|
||||
const wait = 30000 - (now - lastConnectAttempt);
|
||||
console.log(`[Tenant ${TENANT_ID}] Rate limiting: waiting ${wait/1000}s before next attempt`);
|
||||
setTimeout(connectWhatsApp, wait);
|
||||
return;
|
||||
}
|
||||
lastConnectAttempt = now;
|
||||
|
||||
clearWatchdog();
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
console.log(`[Tenant ${TENANT_ID}] Connecting with Baileys v` + version.join('.'));
|
||||
connectionState = 'connecting';
|
||||
scheduleWatchdog();
|
||||
|
||||
sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: true, browser: ['Nexus POS', 'Chrome', '120.0'] });
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
|
||||
browser: ['Ubuntu', 'Chrome', '120.0.0.0'],
|
||||
defaultQueryTimeoutMs: 60000,
|
||||
keepAliveIntervalMs: 15000,
|
||||
markOnlineOnConnect: false,
|
||||
syncFullHistory: false,
|
||||
shouldSyncHistoryMessage: () => false,
|
||||
shouldIgnoreJid: (jid) => false,
|
||||
retryRequestDelayMs: 250,
|
||||
maxMsgRetryCount: 2,
|
||||
connectTimeoutMs: 60000,
|
||||
emitOwnEvents: false,
|
||||
});
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
@@ -36,43 +140,248 @@ async function connectWhatsApp() {
|
||||
console.log(`[Tenant ${TENANT_ID}] QR code generated!`);
|
||||
}
|
||||
if (connection === 'close') {
|
||||
clearWatchdog();
|
||||
connectionState = 'disconnected';
|
||||
qrCode = null;
|
||||
const reason = lastDisconnect?.error?.output?.statusCode;
|
||||
if (reason !== DisconnectReason.loggedOut) { setTimeout(connectWhatsApp, 5000); }
|
||||
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
||||
// Baileys wraps some errors differently; try both paths
|
||||
const reason = statusCode || lastDisconnect?.error?.statusCode;
|
||||
console.log(`[Tenant ${TENANT_ID}] Disconnected, reason:`, reason);
|
||||
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Logged out — clearing auth`);
|
||||
clearAuthState();
|
||||
sock = null;
|
||||
retry440Count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === 440) {
|
||||
// 440 = conflict/replaced. The session data is permanently invalid.
|
||||
// Clean auth immediately and wait 5 min so WhatsApp forgets the old session.
|
||||
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`);
|
||||
clearAuthState();
|
||||
sock = null;
|
||||
retry440Count++;
|
||||
const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min
|
||||
setTimeout(connectWhatsApp, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === 515) {
|
||||
// 515 = stream error, often precedes 440. Treat same as 440.
|
||||
console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`);
|
||||
clearAuthState();
|
||||
sock = null;
|
||||
setTimeout(connectWhatsApp, 300000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === 428) {
|
||||
console.log(`[Tenant ${TENANT_ID}] 428 Server terminated — waiting 60s`);
|
||||
sock = null;
|
||||
setTimeout(connectWhatsApp, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === 408) {
|
||||
// 408 during init queries usually means the server is overloaded
|
||||
// or our auth is partially invalid. Clear auth if this happens repeatedly.
|
||||
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`);
|
||||
sock = null;
|
||||
retry440Count++;
|
||||
if (retry440Count >= 5) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`);
|
||||
clearAuthState();
|
||||
retry440Count = 0;
|
||||
setTimeout(connectWhatsApp, 300000);
|
||||
return;
|
||||
}
|
||||
setTimeout(connectWhatsApp, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other error — wait 30s and retry
|
||||
console.log(`[Tenant ${TENANT_ID}] Reconnecting in 30s... (reason: ${reason})`);
|
||||
sock = null;
|
||||
setTimeout(connectWhatsApp, 30000);
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
clearWatchdog();
|
||||
connectionState = 'open';
|
||||
qrCode = null;
|
||||
retry440Count = 0;
|
||||
updateActivity();
|
||||
scheduleStaleWatchdog();
|
||||
console.log(`[Tenant ${TENANT_ID}] Connected!`);
|
||||
flushSendQueue();
|
||||
}
|
||||
if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log(`[Tenant ${TENANT_ID}] Connected!`); }
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', async ({ messages }) => {
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
|
||||
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || '';
|
||||
console.log(`[Tenant ${TENANT_ID}] From ${phone}: ${text}`);
|
||||
const message = msg.message || {};
|
||||
|
||||
let media_kind = 'text';
|
||||
let media_base64 = null;
|
||||
let media_mimetype = null;
|
||||
let media_caption = null;
|
||||
let media_ptt = false;
|
||||
let latitude = null;
|
||||
let longitude = null;
|
||||
let text = '';
|
||||
|
||||
if (message.conversation) {
|
||||
text = message.conversation;
|
||||
} else if (message.extendedTextMessage) {
|
||||
text = message.extendedTextMessage.text || '';
|
||||
} else if (message.imageMessage) {
|
||||
media_kind = 'image';
|
||||
media_mimetype = message.imageMessage.mimetype;
|
||||
media_caption = message.imageMessage.caption || '';
|
||||
text = media_caption;
|
||||
} else if (message.videoMessage) {
|
||||
media_kind = 'video';
|
||||
media_mimetype = message.videoMessage.mimetype;
|
||||
media_caption = message.videoMessage.caption || '';
|
||||
text = media_caption;
|
||||
} else if (message.audioMessage) {
|
||||
media_kind = 'audio';
|
||||
media_mimetype = message.audioMessage.mimetype;
|
||||
media_ptt = message.audioMessage.ptt || false;
|
||||
} else if (message.locationMessage) {
|
||||
media_kind = 'location';
|
||||
latitude = message.locationMessage.degreesLatitude;
|
||||
longitude = message.locationMessage.degreesLongitude;
|
||||
text = `Ubicación: ${latitude}, ${longitude}`;
|
||||
}
|
||||
|
||||
if (['image', 'video', 'audio'].includes(media_kind)) {
|
||||
try {
|
||||
await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event: 'messages.upsert', data: { key: msg.key, message: msg.message, messageTimestamp: msg.messageTimestamp } }) });
|
||||
} catch (e) { console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message); }
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger: pino({ level: 'silent' }) });
|
||||
if (buffer) media_base64 = buffer.toString('base64');
|
||||
} catch (e) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Media download failed:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
updateActivity();
|
||||
console.log(`[Tenant ${TENANT_ID}] From ${phone}: [${media_kind}] ${text.substring(0, 80)}`);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
const resp = await fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: 'messages.upsert',
|
||||
data: {
|
||||
key: msg.key,
|
||||
message: msg.message,
|
||||
messageTimestamp: msg.messageTimestamp,
|
||||
media_kind,
|
||||
media_base64,
|
||||
media_mimetype,
|
||||
media_caption,
|
||||
media_ptt,
|
||||
latitude,
|
||||
longitude,
|
||||
push_name: msg.pushName || ''
|
||||
}
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (resp.ok) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Webhook delivered OK (${resp.status})`);
|
||||
} else {
|
||||
console.log(`[Tenant ${TENANT_ID}] Webhook returned ${resp.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState }));
|
||||
app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState, queued: sendQueue.length }));
|
||||
app.get('/status', (req, res) => res.json({ state: connectionState, hasQr: !!qrCode }));
|
||||
app.get('/qr', (req, res) => {
|
||||
if (connectionState === 'open') return res.json({ state: 'open', message: 'Already connected' });
|
||||
if (!qrCode) return res.json({ state: connectionState, qr: null, message: 'QR not ready' });
|
||||
res.json({ state: 'qr', qr: qrCode });
|
||||
});
|
||||
app.post('/connect', async (req, res) => { if (!sock) connectWhatsApp(); res.json({ state: connectionState }); });
|
||||
app.post('/send', async (req, res) => {
|
||||
if (connectionState !== 'open') return res.status(400).json({ error: 'Not connected' });
|
||||
const { phone, message } = req.body;
|
||||
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
||||
try { const r = await sock.sendMessage(jid, { text: message }); res.json({ success: true, id: r.key.id }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
app.post('/connect', async (req, res) => {
|
||||
if (sock) {
|
||||
try { await sock.logout(); } catch (e) {}
|
||||
sock = null;
|
||||
}
|
||||
retry440Count = 0;
|
||||
clearAuthState();
|
||||
qrCode = null;
|
||||
connectionState = 'connecting';
|
||||
connectWhatsApp();
|
||||
res.json({ state: connectionState });
|
||||
});
|
||||
app.post('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } qrCode = null; connectionState = 'disconnected'; res.json({ state: 'disconnected' }); });
|
||||
app.post('/send', async (req, res) => {
|
||||
const { phone, message } = req.body;
|
||||
if (!phone || !message) {
|
||||
return res.status(400).json({ error: 'phone and message required' });
|
||||
}
|
||||
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
||||
|
||||
app.listen(PORT, () => { console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`); connectWhatsApp(); });
|
||||
if (connectionState !== 'open' || !sock) {
|
||||
sendQueue.push({ jid, message });
|
||||
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
|
||||
return res.status(202).json({ queued: true, state: connectionState });
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await sock.sendMessage(jid, { text: message });
|
||||
res.json({ success: true, id: r.key.id });
|
||||
} catch (e) {
|
||||
sendQueue.push({ jid, message });
|
||||
console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message);
|
||||
res.status(202).json({ queued: true, error: e.message });
|
||||
}
|
||||
});
|
||||
app.post('/send-image', async (req, res) => {
|
||||
const { phone, caption, base64 } = req.body;
|
||||
if (!phone || !base64) {
|
||||
return res.status(400).json({ error: 'phone and base64 required' });
|
||||
}
|
||||
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
|
||||
|
||||
if (connectionState !== 'open' || !sock) {
|
||||
return res.status(400).json({ error: 'Not connected' });
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const r = await sock.sendMessage(jid, {
|
||||
image: buffer,
|
||||
caption: caption || ''
|
||||
});
|
||||
res.json({ success: true, id: r.key.id });
|
||||
} catch (e) {
|
||||
console.log(`[Tenant ${TENANT_ID}] Send image failed:`, e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/logout', async (req, res) => {
|
||||
if (sock) { await sock.logout(); sock = null; }
|
||||
clearAuthState();
|
||||
qrCode = null;
|
||||
connectionState = 'disconnected';
|
||||
retry440Count = 0;
|
||||
res.json({ state: 'disconnected' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`);
|
||||
connectWhatsApp();
|
||||
});
|
||||
|
||||
138
scripts/assign_categories_estrada.py
Normal file
138
scripts/assign_categories_estrada.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assign categories to Strada inventory based on pcode from TODO.st01.
|
||||
Creates categories if they don't exist and updates inventory.category_id.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
# pcode → category name mapping (inferred from common auto parts codes)
|
||||
PCODE_MAP = {
|
||||
'SU': 'Suspensión',
|
||||
'SO': 'Soportería',
|
||||
'MA': 'Mangueras',
|
||||
'EM': 'Empaques',
|
||||
'HQ': 'Herramientas y Equipo',
|
||||
'AM': 'Amortiguadores',
|
||||
'IG': 'Ignición y Eléctrico',
|
||||
'BD': 'Bandas y Correas',
|
||||
'BL': 'Baleros',
|
||||
'FI': 'Filtros',
|
||||
'HE': 'Herramientas',
|
||||
'PI': 'Pistones y Anillos',
|
||||
'AW': 'Accesorios',
|
||||
'BE': 'Baterías y Eléctrico',
|
||||
'K0': 'Kit de Servicio',
|
||||
'DF': 'Frenos (Discos)',
|
||||
'RE': 'Refrigeración',
|
||||
'PE': 'Perfiles y Sellos',
|
||||
'BG': 'Bujías',
|
||||
'TA': 'Toma de Agua',
|
||||
'CV': 'Conversiones y Kits',
|
||||
'CT': 'Clutch y Transmisión',
|
||||
'CC': 'Cilindros y Componentes',
|
||||
'OT': 'Otros',
|
||||
'SF': 'Soportes y Bases',
|
||||
'BT': 'Bujes y Terminales',
|
||||
'HF': 'Herramientas y Ferretería',
|
||||
'CR': 'Cremalleras y Dirección',
|
||||
'CS': 'Cilindros de Freno',
|
||||
'CF': 'Cables y Frenos',
|
||||
'FG': 'Faros y Iluminación',
|
||||
'TO': 'Tornillería',
|
||||
'AO': 'Aceites y Lubricantes',
|
||||
'BJ': 'Bujes',
|
||||
'FR': 'Frenos',
|
||||
'DR': 'Dirección y Rótulas',
|
||||
'KT': 'Kits de Reparación',
|
||||
'TT': 'Transmisión',
|
||||
'BU': 'Bujes y Soportes',
|
||||
'PC': 'Poleas y Componentes',
|
||||
'CL': 'Clutch',
|
||||
'PG': 'Pegamentos y Adhesivos',
|
||||
'QU': 'Químicos',
|
||||
'TA': 'Toma de Agua',
|
||||
'VC': 'Válvulas y Controles',
|
||||
'MV': 'Motoventiladores',
|
||||
'AS': 'Aspas y Ventiladores',
|
||||
'PO': 'Poleas',
|
||||
'CM': 'Compresores y A/C',
|
||||
'HE': 'Herramientas',
|
||||
'EA': 'Enfriadores y Radiadores',
|
||||
'CO': 'Conectores',
|
||||
'CX': 'Conectores y Cables',
|
||||
'TR': 'Tren Delantero',
|
||||
'AB': 'Abrazaderas',
|
||||
'FU': 'Fusibles',
|
||||
'MR': 'Mangueras de Freno',
|
||||
'TO': 'Tornillería',
|
||||
'RA': 'Radiadores',
|
||||
'MC': 'Mangueras y Conectores',
|
||||
}
|
||||
|
||||
def assign_categories(tenant_id):
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get or create categories
|
||||
cur.execute("SELECT id, name FROM part_categories")
|
||||
existing = {name: id for id, name in cur.fetchall()}
|
||||
|
||||
created = 0
|
||||
category_map = {} # pcode -> category_id
|
||||
|
||||
for pcode, name in PCODE_MAP.items():
|
||||
if name in existing:
|
||||
category_map[pcode] = existing[name]
|
||||
else:
|
||||
cur.execute("INSERT INTO part_categories (name) VALUES (%s) RETURNING id", (name,))
|
||||
cat_id = cur.fetchone()[0]
|
||||
existing[name] = cat_id
|
||||
category_map[pcode] = cat_id
|
||||
created += 1
|
||||
|
||||
print(f"Categories: {len(existing)} total, {created} created")
|
||||
|
||||
# Update inventory items based on location (pcode) field
|
||||
updated = 0
|
||||
for pcode, cat_id in category_map.items():
|
||||
cur.execute("""
|
||||
UPDATE inventory
|
||||
SET category_id = %s
|
||||
WHERE location = %s AND category_id IS NULL
|
||||
""", (cat_id, pcode))
|
||||
updated += cur.rowcount
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print(f"Updated {updated:,} inventory items with categories")
|
||||
|
||||
# Show uncategorized items
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT location, COUNT(*) FROM inventory
|
||||
WHERE category_id IS NULL AND location IS NOT NULL
|
||||
GROUP BY location ORDER BY COUNT(*) DESC
|
||||
""")
|
||||
uncategorized = cur.fetchall()
|
||||
if uncategorized:
|
||||
print(f"\nUncategorized items by pcode:")
|
||||
for pcode, cnt in uncategorized[:20]:
|
||||
print(f" {pcode}: {cnt:,}")
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--tenant', type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
assign_categories(args.tenant)
|
||||
310
scripts/import_estrada_st01.py
Normal file
310
scripts/import_estrada_st01.py
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nexus Autoparts — Import Strada TODO.st01 Inventory
|
||||
Parses fixed-width report and imports into tenant DB using PostgreSQL COPY.
|
||||
|
||||
Usage:
|
||||
python3 import_estrada_st01.py --tenant=28 --file=/tmp/todo_st01.txt --branch=1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||
import psycopg2
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
# Margins applied to Cost A
|
||||
MARGIN_PRICE_1 = 1.35 # mostrador / retail
|
||||
MARGIN_PRICE_2 = 1.25 # taller / workshop
|
||||
MARGIN_PRICE_3 = 1.15 # mayoreo / wholesale
|
||||
|
||||
|
||||
def parse_number(val, default=0):
|
||||
"""Parse a string that might be '$1,234.56' or '.000' or empty."""
|
||||
if not val:
|
||||
return default
|
||||
cleaned = str(val).replace('$', '').replace(',', '').replace(' ', '').strip()
|
||||
if cleaned == '.':
|
||||
return default
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def parse_st01_line(line):
|
||||
"""Parse a single line from TODO.st01 fixed-width format."""
|
||||
line = line.rstrip('\n')
|
||||
|
||||
# Skip short lines
|
||||
if len(line) < 80:
|
||||
return None
|
||||
|
||||
# Skip header/footer lines
|
||||
if 'Page:' in line or 'General Inventory' in line or 'STRADA AUTO' in line:
|
||||
return None
|
||||
if 'Tienda:' in line and 'Page:' in line:
|
||||
return None
|
||||
if line.strip().startswith('Line Part Number'):
|
||||
return None
|
||||
if line.strip().startswith('----'):
|
||||
return None
|
||||
|
||||
# Fixed-width columns based on header:
|
||||
# Line Part Number Description Addl description Pcode Instk Min 1 Max 1 Cost A Cost B codes[1]
|
||||
# ---- ---------------- ---------------- ---------------- ----- ------- ------ ------ ---------- ---------- --------
|
||||
marca = line[0:5].strip()
|
||||
part_number_raw = line[5:21].strip()
|
||||
description = line[22:39].strip()
|
||||
addl_desc = line[40:56].strip()
|
||||
pcode = line[56:61].strip()
|
||||
instk = line[62:70].strip()
|
||||
min1 = line[71:77].strip()
|
||||
max1 = line[78:84].strip()
|
||||
cost_a = line[85:95].strip()
|
||||
cost_b = line[96:106].strip()
|
||||
codes = line[107:].strip() if len(line) > 107 else ''
|
||||
|
||||
if not part_number_raw:
|
||||
return None
|
||||
|
||||
# Build composite part number: MARCA-NUMERO
|
||||
# Some marcas are empty (fallback to pcode or generic)
|
||||
marca_safe = marca if marca else 'GEN'
|
||||
part_number = f"{marca_safe}-{part_number_raw}"
|
||||
|
||||
# Build name from description + additional description
|
||||
name = description
|
||||
if addl_desc and addl_desc != '?':
|
||||
name = f"{description} {addl_desc}".strip()
|
||||
|
||||
if not name:
|
||||
return None
|
||||
|
||||
cost = parse_number(cost_a, 0)
|
||||
stock = int(parse_number(instk, 0))
|
||||
min_stock = int(parse_number(min1, 0))
|
||||
max_stock = int(parse_number(max1, 0))
|
||||
|
||||
# Calculate prices with margins
|
||||
price_1 = round(cost * MARGIN_PRICE_1, 2) if cost > 0 else 0
|
||||
price_2 = round(cost * MARGIN_PRICE_2, 2) if cost > 0 else 0
|
||||
price_3 = round(cost * MARGIN_PRICE_3, 2) if cost > 0 else 0
|
||||
|
||||
return {
|
||||
'part_number': part_number,
|
||||
'name': name[:300], # truncate to schema limit
|
||||
'brand': marca_safe,
|
||||
'cost': cost,
|
||||
'price_1': price_1,
|
||||
'price_2': price_2,
|
||||
'price_3': price_3,
|
||||
'stock': stock,
|
||||
'min_stock': min_stock,
|
||||
'max_stock': max_stock,
|
||||
'location': pcode,
|
||||
'unit': 'PZA',
|
||||
'tax_rate': 0.16,
|
||||
}
|
||||
|
||||
|
||||
def import_st01(tenant_id, file_path, branch_id=1):
|
||||
print(f"=== Importing Strada inventory ===")
|
||||
print(f"Tenant: {tenant_id}")
|
||||
print(f"File: {file_path}")
|
||||
print(f"Branch: {branch_id}")
|
||||
print()
|
||||
|
||||
# Parse file
|
||||
print("Parsing fixed-width file...")
|
||||
parsed = []
|
||||
skipped = 0
|
||||
start_time = time.time()
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
row = parse_st01_line(line)
|
||||
if row is None:
|
||||
skipped += 1
|
||||
continue
|
||||
parsed.append(row)
|
||||
|
||||
parse_time = time.time() - start_time
|
||||
print(f" Parsed: {len(parsed):,} rows")
|
||||
print(f" Skipped: {skipped:,} lines (headers/empty)")
|
||||
print(f" Time: {parse_time:.1f}s")
|
||||
print()
|
||||
|
||||
if not parsed:
|
||||
print("ERROR: No data rows found.")
|
||||
return
|
||||
|
||||
# Show sample
|
||||
print("Sample rows:")
|
||||
for row in parsed[:5]:
|
||||
print(f" {row['part_number']:20} | {row['name'][:40]:40} | stk={row['stock']:>4} | cost={row['cost']:>10.2f} | p1={row['price_1']:>10.2f}")
|
||||
print()
|
||||
|
||||
# Connect to tenant DB
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Ensure branch exists
|
||||
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
|
||||
if not cur.fetchone():
|
||||
print(f"ERROR: Branch {branch_id} does not exist. Creating default branch...")
|
||||
cur.execute("INSERT INTO branches (name) VALUES ('Principal') RETURNING id")
|
||||
branch_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
print(f" Created branch id={branch_id}")
|
||||
|
||||
# Step 1: Create temp table and COPY data
|
||||
print("Creating temp table...")
|
||||
cur.execute("""
|
||||
DROP TABLE IF EXISTS tmp_st01_import;
|
||||
CREATE TEMP TABLE tmp_st01_import (
|
||||
part_number VARCHAR(100),
|
||||
name VARCHAR(300),
|
||||
brand VARCHAR(100),
|
||||
cost NUMERIC(12,2),
|
||||
price_1 NUMERIC(12,2),
|
||||
price_2 NUMERIC(12,2),
|
||||
price_3 NUMERIC(12,2),
|
||||
stock INTEGER,
|
||||
min_stock INTEGER,
|
||||
max_stock INTEGER,
|
||||
location VARCHAR(50),
|
||||
unit VARCHAR(20),
|
||||
tax_rate NUMERIC(5,4)
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
print("COPY to temp table...")
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, lineterminator='\n')
|
||||
for row in parsed:
|
||||
writer.writerow([
|
||||
row['part_number'],
|
||||
row['name'],
|
||||
row['brand'],
|
||||
row['cost'],
|
||||
row['price_1'],
|
||||
row['price_2'],
|
||||
row['price_3'],
|
||||
row['stock'],
|
||||
row['min_stock'],
|
||||
row['max_stock'],
|
||||
row['location'],
|
||||
row['unit'],
|
||||
row['tax_rate'],
|
||||
])
|
||||
|
||||
csv_buffer.seek(0)
|
||||
cur.copy_expert(
|
||||
"""COPY tmp_st01_import (
|
||||
part_number, name, brand, cost, price_1, price_2, price_3,
|
||||
stock, min_stock, max_stock, location, unit, tax_rate
|
||||
) FROM STDIN WITH (FORMAT CSV)""",
|
||||
csv_buffer
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM tmp_st01_import")
|
||||
copied = cur.fetchone()[0]
|
||||
print(f" Copied: {copied:,} rows")
|
||||
print()
|
||||
|
||||
# Step 2: Insert into inventory
|
||||
print("Inserting into inventory...")
|
||||
start_time = time.time()
|
||||
cur.execute("""
|
||||
INSERT INTO inventory (
|
||||
branch_id, part_number, name, brand,
|
||||
cost, price_1, price_2, price_3,
|
||||
min_stock, max_stock, location, unit, tax_rate, is_active
|
||||
)
|
||||
SELECT
|
||||
%s, part_number, name, brand,
|
||||
cost, price_1, price_2, price_3,
|
||||
min_stock, max_stock, location, unit, tax_rate, TRUE
|
||||
FROM tmp_st01_import
|
||||
ON CONFLICT (branch_id, part_number) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
brand = EXCLUDED.brand,
|
||||
cost = EXCLUDED.cost,
|
||||
price_1 = EXCLUDED.price_1,
|
||||
price_2 = EXCLUDED.price_2,
|
||||
price_3 = EXCLUDED.price_3,
|
||||
min_stock = EXCLUDED.min_stock,
|
||||
max_stock = EXCLUDED.max_stock,
|
||||
location = EXCLUDED.location,
|
||||
is_active = TRUE
|
||||
""", (branch_id,))
|
||||
conn.commit()
|
||||
insert_time = time.time() - start_time
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM inventory WHERE branch_id = %s", (branch_id,))
|
||||
total_inventory = cur.fetchone()[0]
|
||||
print(f" Inventory rows: {total_inventory:,}")
|
||||
print(f" Time: {insert_time:.1f}s")
|
||||
print()
|
||||
|
||||
# Step 3: Create stock operations for items with stock > 0
|
||||
print("Creating initial stock operations...")
|
||||
start_time = time.time()
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_operations (
|
||||
inventory_id, branch_id, operation_type, quantity,
|
||||
cost_at_time, reference_type, notes
|
||||
)
|
||||
SELECT
|
||||
i.id,
|
||||
i.branch_id,
|
||||
'INITIAL',
|
||||
t.stock,
|
||||
i.cost,
|
||||
'IMPORT',
|
||||
'Importado desde TODO.st01'
|
||||
FROM tmp_st01_import t
|
||||
JOIN inventory i ON i.part_number = t.part_number AND i.branch_id = %s
|
||||
WHERE t.stock > 0
|
||||
""", (branch_id,))
|
||||
conn.commit()
|
||||
ops_time = time.time() - start_time
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM inventory_operations WHERE operation_type = 'INITIAL'")
|
||||
total_ops = cur.fetchone()[0]
|
||||
print(f" Stock operations created: {total_ops:,}")
|
||||
print(f" Time: {ops_time:.1f}s")
|
||||
print()
|
||||
|
||||
# Cleanup
|
||||
cur.execute("DROP TABLE IF EXISTS tmp_st01_import")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print("=== Import complete ===")
|
||||
print(f"Total inventory items: {total_inventory:,}")
|
||||
print(f"Items with stock > 0: {total_ops:,}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Import Strada TODO.st01 into Nexus tenant')
|
||||
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
|
||||
parser.add_argument('--file', required=True, help='Path to TODO.st01 file')
|
||||
parser.add_argument('--branch', type=int, default=1, help='Branch ID (default: 1)')
|
||||
args = parser.parse_args()
|
||||
|
||||
import_st01(args.tenant, args.file, args.branch)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
439
scripts/import_pdf_catalog.py
Executable file
439
scripts/import_pdf_catalog.py
Executable file
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Import aftermarket parts catalog from PDF into Nexus Autoparts DB.
|
||||
|
||||
Usage:
|
||||
# Extract and preview (generates CSV for review)
|
||||
python3 scripts/import_pdf_catalog.py extract catalogo_bosch.pdf "BOSCH" --output bosch_preview.csv
|
||||
|
||||
# Import after reviewing CSV
|
||||
python3 scripts/import_pdf_catalog.py import bosch_preview.csv "BOSCH"
|
||||
|
||||
The CSV should have columns:
|
||||
part_number, name, price_usd, applications
|
||||
|
||||
Applications column (optional): comma-separated vehicle descriptions like:
|
||||
"TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019"
|
||||
|
||||
If applications is empty, the part will be created but not linked to vehicles.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import psycopg2
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for config imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "pos"))
|
||||
|
||||
MASTER_DB_URL = os.environ.get("MASTER_DB_URL", "postgresql://postgres@localhost/nexus_autoparts")
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(MASTER_DB_URL)
|
||||
|
||||
|
||||
def pdf_to_text(pdf_path):
|
||||
"""Extract text from PDF using pdftotext (preserves layout)."""
|
||||
result = subprocess.run(
|
||||
["pdftotext", "-layout", pdf_path, "-"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"pdftotext failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def extract_lines_fuzzy(text, min_cols=2):
|
||||
"""
|
||||
Heuristic table extractor.
|
||||
Looks for lines that have:
|
||||
- A part number pattern (alphanumeric with dashes/slashes, 3+ chars)
|
||||
- Some description text
|
||||
Returns list of dicts with raw columns.
|
||||
"""
|
||||
rows = []
|
||||
lines = text.splitlines()
|
||||
|
||||
# Part number patterns: BOSCH 0 986 AF1 041, MOOG K80001, NGK BKR6E, etc.
|
||||
part_number_patterns = [
|
||||
re.compile(r'\b[0-9A-Z]{3,}(?:[-\s/][0-9A-Z]+){1,}\b'), # codes with separators
|
||||
re.compile(r'\b[A-Z]{1,3}\d{3,}[A-Z0-9]*\b'), # MOOG K80001, NGK BKR6E
|
||||
re.compile(r'\b\d{3,}[A-Z]{1,3}\d+\b'), # 123ABC45
|
||||
]
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if len(line) < 10:
|
||||
continue
|
||||
|
||||
# Try to find a part number
|
||||
part_number = None
|
||||
for pat in part_number_patterns:
|
||||
m = pat.search(line)
|
||||
if m:
|
||||
part_number = m.group(0).strip()
|
||||
break
|
||||
|
||||
if not part_number:
|
||||
continue
|
||||
|
||||
# Split line by 2+ spaces to get columns
|
||||
cols = [c.strip() for c in re.split(r'\s{2,}', line) if c.strip()]
|
||||
if len(cols) < min_cols:
|
||||
continue
|
||||
|
||||
# Heuristic: part number is usually first or second column
|
||||
# The rest is description, possibly with price at the end
|
||||
name_parts = []
|
||||
price = None
|
||||
for col in cols:
|
||||
if col == part_number:
|
||||
continue
|
||||
# Price detection
|
||||
price_m = re.match(r'^\$?([0-9]{1,6}(?:\.[0-9]{1,2})?)$', col.replace(',', ''))
|
||||
if price_m and not price:
|
||||
price = float(price_m.group(1))
|
||||
continue
|
||||
name_parts.append(col)
|
||||
|
||||
name = ' '.join(name_parts) if name_parts else part_number
|
||||
# Clean up name
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
if len(name) < 3:
|
||||
name = part_number
|
||||
|
||||
rows.append({
|
||||
'part_number': part_number,
|
||||
'name': name,
|
||||
'price_usd': price,
|
||||
'applications': '',
|
||||
'raw': line,
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def preview_rows(rows, limit=20):
|
||||
print(f"\nExtracted {len(rows)} candidate rows. First {limit}:")
|
||||
print("-" * 100)
|
||||
for i, r in enumerate(rows[:limit]):
|
||||
print(f"{i+1}. PN: {r['part_number'][:30]:30s} | Name: {r['name'][:50]:50s} | Price: {r['price_usd']}")
|
||||
print("-" * 100)
|
||||
|
||||
|
||||
def save_csv(rows, path):
|
||||
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=['part_number', 'name', 'price_usd', 'applications'])
|
||||
writer.writeheader()
|
||||
for r in rows:
|
||||
writer.writerow({
|
||||
'part_number': r['part_number'],
|
||||
'name': r['name'],
|
||||
'price_usd': r['price_usd'] or '',
|
||||
'applications': r['applications'],
|
||||
})
|
||||
print(f"Saved preview to {path}")
|
||||
|
||||
|
||||
def load_csv(path):
|
||||
rows = []
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
price = row.get('price_usd', '')
|
||||
try:
|
||||
price = float(price) if price else None
|
||||
except ValueError:
|
||||
price = None
|
||||
rows.append({
|
||||
'part_number': row.get('part_number', '').strip(),
|
||||
'name': row.get('name', '').strip(),
|
||||
'price_usd': price,
|
||||
'applications': row.get('applications', '').strip(),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_manufacturer(cur, name):
|
||||
"""Get or create manufacturer. Returns id_manufacture."""
|
||||
cur.execute(
|
||||
"SELECT id_manufacture FROM manufacturers WHERE UPPER(name_manufacture) = UPPER(%s)",
|
||||
(name,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
# Insert new manufacturer
|
||||
cur.execute(
|
||||
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
|
||||
(name.upper() if len(name) <= 6 else name,)
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def resolve_or_create_part(cur, oem_part_number, name):
|
||||
"""
|
||||
parts.oem_part_number has UNIQUE index.
|
||||
If it exists, return id_part. If not, insert.
|
||||
"""
|
||||
cur.execute(
|
||||
"SELECT id_part, name_part FROM parts WHERE oem_part_number = %s",
|
||||
(oem_part_number,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
# Need a group_id. Use 'General' group as default.
|
||||
cur.execute("SELECT id_part_group FROM part_groups WHERE name_part_group = 'General' LIMIT 1")
|
||||
grow = cur.fetchone()
|
||||
group_id = grow[0] if grow else None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO parts (oem_part_number, name_part, group_id)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id_part
|
||||
""",
|
||||
(oem_part_number, name, group_id)
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def parse_applications(app_text):
|
||||
"""
|
||||
Parse text like 'TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019'
|
||||
into list of (brand, model, year_from, year_to).
|
||||
"""
|
||||
if not app_text:
|
||||
return []
|
||||
|
||||
results = []
|
||||
# Split by commas or slashes
|
||||
entries = re.split(r'[,;/]', app_text)
|
||||
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
# Pattern: BRAND MODEL YEAR-YEAR or BRAND MODEL YEAR
|
||||
m = re.match(
|
||||
r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30}?)\s+(\d{4})(?:\s*-\s*(\d{4}))?$',
|
||||
entry.upper().strip()
|
||||
)
|
||||
if m:
|
||||
brand = m.group(1).strip()
|
||||
model = m.group(2).strip()
|
||||
year_from = int(m.group(3))
|
||||
year_to = int(m.group(4)) if m.group(4) else year_from
|
||||
results.append((brand, model, year_from, year_to))
|
||||
else:
|
||||
# Try looser pattern: just BRAND MODEL
|
||||
m2 = re.match(r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30})$', entry.upper().strip())
|
||||
if m2:
|
||||
results.append((m2.group(1).strip(), m2.group(2).strip(), None, None))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def resolve_mye_ids(cur, brand_name, model_name, year_from, year_to):
|
||||
"""Find MYE ids matching brand/model/year range."""
|
||||
myes = []
|
||||
|
||||
# Find brand
|
||||
cur.execute("SELECT id_brand FROM brands WHERE UPPER(name_brand) = UPPER(%s)", (brand_name,))
|
||||
brow = cur.fetchone()
|
||||
if not brow:
|
||||
return myes
|
||||
brand_id = brow[0]
|
||||
|
||||
# Find model (fuzzy)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id_model, name_model FROM models
|
||||
WHERE brand_id = %s AND UPPER(name_model) LIKE UPPER(%s)
|
||||
ORDER BY name_model
|
||||
LIMIT 5
|
||||
""",
|
||||
(brand_id, f"%{model_name}%")
|
||||
)
|
||||
models = cur.fetchall()
|
||||
if not models:
|
||||
return myes
|
||||
|
||||
# Use first match
|
||||
model_id = models[0][0]
|
||||
|
||||
# Find MYEs for year range
|
||||
if year_from and year_to:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mye.id_mye FROM model_year_engine mye
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE mye.model_id = %s AND y.year_car BETWEEN %s AND %s
|
||||
""",
|
||||
(model_id, year_from, year_to)
|
||||
)
|
||||
elif year_from:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mye.id_mye FROM model_year_engine mye
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE mye.model_id = %s AND y.year_car = %s
|
||||
""",
|
||||
(model_id, year_from)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id_mye FROM model_year_engine WHERE model_id = %s",
|
||||
(model_id,)
|
||||
)
|
||||
|
||||
myes = [r[0] for r in cur.fetchall()]
|
||||
return myes
|
||||
|
||||
|
||||
def import_rows(rows, manufacturer_name, dry_run=False):
|
||||
conn = get_db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
manufacturer_id = resolve_manufacturer(cur, manufacturer_name)
|
||||
print(f"Manufacturer '{manufacturer_name}' → id={manufacturer_id}")
|
||||
|
||||
inserted_parts = 0
|
||||
inserted_am = 0
|
||||
linked_vehicles = 0
|
||||
skipped = 0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
pn = row['part_number']
|
||||
name = row['name'] or pn
|
||||
price = row['price_usd']
|
||||
|
||||
if not pn:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY] {pn} | {name[:40]} | ${price}")
|
||||
continue
|
||||
|
||||
# 1. Ensure part exists in parts table
|
||||
part_id = resolve_or_create_part(cur, pn, name)
|
||||
|
||||
# 2. Insert/upsert aftermarket_parts
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id_aftermarket_parts FROM aftermarket_parts
|
||||
WHERE part_number = %s AND manufacturer_id = %s
|
||||
""",
|
||||
(pn, manufacturer_id)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
# Update
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE aftermarket_parts
|
||||
SET name_aftermarket_parts = %s,
|
||||
price_usd = COALESCE(%s, price_usd),
|
||||
oem_part_id = %s
|
||||
WHERE id_aftermarket_parts = %s
|
||||
""",
|
||||
(name, price, part_id, existing[0])
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO aftermarket_parts
|
||||
(oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, price_usd)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(part_id, manufacturer_id, pn, name, price)
|
||||
)
|
||||
inserted_am += 1
|
||||
|
||||
inserted_parts += 1
|
||||
|
||||
# 3. Link vehicles if applications provided
|
||||
apps = row.get('applications', '')
|
||||
if apps:
|
||||
parsed = parse_applications(apps)
|
||||
for brand, model, yf, yt in parsed:
|
||||
myes = resolve_mye_ids(cur, brand, model, yf, yt)
|
||||
for mye_id in myes:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO vehicle_parts (part_id, model_year_engine_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(part_id, mye_id)
|
||||
)
|
||||
linked_vehicles += 1
|
||||
|
||||
if (i + 1) % 100 == 0:
|
||||
print(f" ... processed {i+1}/{len(rows)}")
|
||||
|
||||
conn.commit()
|
||||
print(f"\nDone!")
|
||||
print(f" Parts processed: {inserted_parts}")
|
||||
print(f" Aftermarket parts inserted/updated: {inserted_am}")
|
||||
print(f" Vehicle links created: {linked_vehicles}")
|
||||
print(f" Skipped (no PN): {skipped}")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Import aftermarket catalog from PDF')
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
|
||||
# Extract command
|
||||
ext = subparsers.add_parser('extract', help='Extract PDF to preview CSV')
|
||||
ext.add_argument('pdf', help='Path to PDF file')
|
||||
ext.add_argument('manufacturer', help='Manufacturer name')
|
||||
ext.add_argument('--output', '-o', default='catalog_preview.csv', help='Output CSV path')
|
||||
|
||||
# Import command
|
||||
imp = subparsers.add_parser('import', help='Import reviewed CSV to DB')
|
||||
imp.add_argument('csv', help='Path to reviewed CSV')
|
||||
imp.add_argument('manufacturer', help='Manufacturer name')
|
||||
imp.add_argument('--dry-run', action='store_true', help='Preview without writing to DB')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'extract':
|
||||
print(f"Extracting {args.pdf}...")
|
||||
text = pdf_to_text(args.pdf)
|
||||
rows = extract_lines_fuzzy(text)
|
||||
preview_rows(rows)
|
||||
save_csv(rows, args.output)
|
||||
print(f"\nNext step: Review {args.output}, add 'applications' column if needed,")
|
||||
print(f"then run: python3 scripts/import_pdf_catalog.py import {args.output} '{args.manufacturer}'")
|
||||
|
||||
elif args.command == 'import':
|
||||
rows = load_csv(args.csv)
|
||||
print(f"Loaded {len(rows)} rows from {args.csv}")
|
||||
import_rows(rows, args.manufacturer, dry_run=args.dry_run)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
231
scripts/qwen_batch_compat.py
Normal file
231
scripts/qwen_batch_compat.py
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mass QWEN vehicle compatibility processor for Strada.
|
||||
Processes inventory items in batches of 5 with parallel workers.
|
||||
|
||||
Usage:
|
||||
python3 qwen_batch_compat.py --tenant=28 --batch-size=5 --workers=10 --checkpoint=qwen_progress.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from threading import Lock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||
import psycopg2
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services.qwen_fitment import get_vehicle_fitment
|
||||
|
||||
DEFAULT_CHECKPOINT_FILE = '/tmp/qwen_progress.json'
|
||||
PROGRESS_LOCK = Lock()
|
||||
|
||||
|
||||
def load_checkpoint(checkpoint_file):
|
||||
if os.path.exists(checkpoint_file):
|
||||
with open(checkpoint_file, 'r') as f:
|
||||
return set(json.load(f))
|
||||
return set()
|
||||
|
||||
|
||||
def save_checkpoint(checkpoint_file, processed_ids):
|
||||
with PROGRESS_LOCK:
|
||||
with open(checkpoint_file, 'w') as f:
|
||||
json.dump(list(processed_ids), f)
|
||||
|
||||
|
||||
def get_pending_items(tenant_id, processed_ids, limit=None):
|
||||
"""Get inventory items that haven't been processed yet."""
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get items without vehicle compatibility records
|
||||
if processed_ids:
|
||||
placeholders = ','.join(['%s'] * len(processed_ids))
|
||||
query = f"""
|
||||
SELECT id, part_number, name, brand
|
||||
FROM inventory
|
||||
WHERE id NOT IN ({placeholders})
|
||||
AND is_active = true
|
||||
ORDER BY id
|
||||
"""
|
||||
cur.execute(query, tuple(processed_ids))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, brand
|
||||
FROM inventory
|
||||
WHERE is_active = true
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if limit:
|
||||
rows = rows[:limit]
|
||||
return rows
|
||||
|
||||
|
||||
def process_single_item(item_id, part_number, name, brand):
|
||||
"""Process one item with QWEN."""
|
||||
try:
|
||||
result = get_vehicle_fitment(part_number, name, brand)
|
||||
return {
|
||||
'item_id': item_id,
|
||||
'part_number': part_number,
|
||||
'success': True,
|
||||
'vehicles': result.get('vehicles', []),
|
||||
'confidence': result.get('confidence', 0),
|
||||
'notes': result.get('notes', ''),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
'item_id': item_id,
|
||||
'part_number': part_number,
|
||||
'success': False,
|
||||
'error': str(exc),
|
||||
}
|
||||
|
||||
|
||||
def save_results(tenant_id, results):
|
||||
"""Save QWEN results to inventory_vehicle_compat."""
|
||||
if not results:
|
||||
return 0
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
saved = 0
|
||||
for result in results:
|
||||
if not result.get('success'):
|
||||
continue
|
||||
|
||||
item_id = result['item_id']
|
||||
vehicles = result.get('vehicles', [])[:200] # Limit to 200 vehicles per item
|
||||
confidence = result.get('confidence', 0)
|
||||
|
||||
for v in vehicles:
|
||||
mye_id = v.get('mye_id')
|
||||
if mye_id:
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, source, confidence, make, model, year, created_at)
|
||||
VALUES (%s, %s, 'qwen_ai', %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
|
||||
""", (item_id, mye_id, confidence, v.get('make'), v.get('model'), v.get('year')))
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, source, confidence, make, model, year, engine, engine_code, created_at)
|
||||
VALUES (%s, NULL, 'qwen_ai', %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (item_id, confidence, v.get('make'), v.get('model'), v.get('year'), v.get('engine'), v.get('engine_code')))
|
||||
saved += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return saved
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Batch QWEN vehicle compatibility processor')
|
||||
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
|
||||
parser.add_argument('--workers', type=int, default=10, help='Number of parallel workers')
|
||||
parser.add_argument('--limit', type=int, default=None, help='Max items to process (None = all)')
|
||||
parser.add_argument('--checkpoint-file', default=DEFAULT_CHECKPOINT_FILE, help='Progress checkpoint file')
|
||||
args = parser.parse_args()
|
||||
|
||||
checkpoint_file = args.checkpoint_file
|
||||
|
||||
print(f"=== QWEN Batch Compatibility Processor ===", flush=True)
|
||||
print(f"Tenant: {args.tenant}", flush=True)
|
||||
print(f"Workers: {args.workers}", flush=True)
|
||||
print(f"Checkpoint: {args.checkpoint_file}", flush=True)
|
||||
print(flush=True)
|
||||
|
||||
# Load checkpoint
|
||||
processed_ids = load_checkpoint(checkpoint_file)
|
||||
print(f"Previously processed: {len(processed_ids):,} items", flush=True)
|
||||
|
||||
# Get pending items
|
||||
pending = get_pending_items(args.tenant, processed_ids, args.limit)
|
||||
print(f"Pending items: {len(pending):,}", flush=True)
|
||||
|
||||
if not pending:
|
||||
print("Nothing to process!", flush=True)
|
||||
return
|
||||
|
||||
# Process with thread pool
|
||||
total = len(pending)
|
||||
completed = 0
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
total_vehicles = 0
|
||||
start_time = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
future_to_item = {
|
||||
executor.submit(process_single_item, item[0], item[1], item[2], item[3]): item
|
||||
for item in pending
|
||||
}
|
||||
|
||||
batch_results = []
|
||||
batch_size = 50 # Save to DB every N items
|
||||
|
||||
for future in as_completed(future_to_item):
|
||||
item = future_to_item[future]
|
||||
try:
|
||||
result = future.result(timeout=180)
|
||||
batch_results.append(result)
|
||||
|
||||
if result['success']:
|
||||
success_count += 1
|
||||
total_vehicles += len(result.get('vehicles', []))
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
processed_ids.add(result['item_id'])
|
||||
|
||||
except Exception as exc:
|
||||
fail_count += 1
|
||||
processed_ids.add(item[0])
|
||||
print(f"Worker exception for {item[1]}: {exc}")
|
||||
|
||||
completed += 1
|
||||
|
||||
# Save checkpoint and DB batch periodically
|
||||
if len(batch_results) >= batch_size:
|
||||
save_checkpoint(checkpoint_file, list(processed_ids))
|
||||
saved = save_results(args.tenant, batch_results)
|
||||
batch_results = []
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
rate = completed / elapsed if elapsed > 0 else 0
|
||||
eta = (total - completed) / rate if rate > 0 else 0
|
||||
|
||||
print(f" Progress: {completed:,}/{total:,} ({100*completed/total:.1f}%) | "
|
||||
f"Success: {success_count} | Fail: {fail_count} | "
|
||||
f"Vehicles: {total_vehicles} | "
|
||||
f"Rate: {rate:.1f} items/min | ETA: {eta/60:.0f} min", flush=True)
|
||||
|
||||
# Final save
|
||||
if batch_results:
|
||||
save_results(args.tenant, batch_results)
|
||||
save_checkpoint(checkpoint_file, list(processed_ids))
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\n=== Complete ===", flush=True)
|
||||
print(f"Processed: {completed:,}", flush=True)
|
||||
print(f"Success: {success_count}", flush=True)
|
||||
print(f"Failed: {fail_count}", flush=True)
|
||||
print(f"Total vehicles found: {total_vehicles}", flush=True)
|
||||
print(f"Elapsed: {elapsed/3600:.1f} hours", flush=True)
|
||||
print(f"Avg rate: {completed/(elapsed/60):.1f} items/min", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
321
scripts/sync_estrada_marketplace_full.py
Normal file
321
scripts/sync_estrada_marketplace_full.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nexus Autoparts — Sync ALL Strada inventory to marketplace (backfill 133k items).
|
||||
|
||||
Extends warehouse_inventory with seller listings for parts that don't match
|
||||
the OEM catalog. Items that DO match keep their part_id; unmatched items
|
||||
get part_id=NULL with seller_part_number / seller_part_name populated.
|
||||
|
||||
Usage:
|
||||
export MASTER_DB_URL="postgresql://user:pass@localhost/nexus_autoparts"
|
||||
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@localhost/{db_name}"
|
||||
python3 sync_estrada_marketplace_full.py --tenant=28 --bodega=7 --branch=1
|
||||
|
||||
Safe to re-run: uses UPSERT semantics.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||
import psycopg2
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
BATCH_SIZE = 5000
|
||||
|
||||
|
||||
def get_master_conn():
|
||||
from config import MASTER_DB_URL
|
||||
return psycopg2.connect(MASTER_DB_URL)
|
||||
|
||||
|
||||
def load_catalog_maps(master_conn):
|
||||
"""Pre-load OEM part_number → part_id and cross-reference maps."""
|
||||
cur = master_conn.cursor()
|
||||
|
||||
print("[1/5] Loading OEM catalog...")
|
||||
cur.execute("SELECT id_part, oem_part_number FROM parts")
|
||||
oem_map = {}
|
||||
for row in cur:
|
||||
pn = row[1].strip() if row[1] else ''
|
||||
if pn:
|
||||
oem_map[pn] = row[0]
|
||||
|
||||
print("[2/5] Loading cross-references...")
|
||||
cur.execute("SELECT cross_reference_number, part_id FROM part_cross_references")
|
||||
cross_map = {}
|
||||
for row in cur:
|
||||
pn = row[0].strip() if row[0] else ''
|
||||
if pn:
|
||||
cross_map[pn] = row[1]
|
||||
|
||||
cur.close()
|
||||
print(f" OEM parts: {len(oem_map):,} | Cross-references: {len(cross_map):,}")
|
||||
return oem_map, cross_map
|
||||
|
||||
|
||||
def read_tenant_inventory(tenant_conn, branch_id):
|
||||
"""Read all active inventory items from tenant DB."""
|
||||
cur = tenant_conn.cursor()
|
||||
print("[3/5] Reading tenant inventory...")
|
||||
|
||||
# Join with categories to get category name
|
||||
cur.execute("""
|
||||
SELECT
|
||||
i.id,
|
||||
i.part_number,
|
||||
i.name,
|
||||
i.price_1,
|
||||
COALESCE(iss.stock, 0) AS stock,
|
||||
c.name AS category_name
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
|
||||
LEFT JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE i.is_active = true
|
||||
AND (i.branch_id = %s OR %s IS NULL)
|
||||
ORDER BY i.id
|
||||
""", (branch_id, branch_id))
|
||||
|
||||
items = []
|
||||
for row in cur:
|
||||
items.append({
|
||||
'id': row[0],
|
||||
'part_number': (row[1] or '').strip(),
|
||||
'name': (row[2] or '').strip(),
|
||||
'price': float(row[3] or 0),
|
||||
'stock': int(row[4] or 0),
|
||||
'category': (row[5] or '').strip(),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
print(f" Tenant items: {len(items):,}")
|
||||
return items
|
||||
|
||||
|
||||
def classify_items(items, oem_map, cross_map):
|
||||
"""Split items into OEM-matched and seller listings."""
|
||||
matched = []
|
||||
seller = []
|
||||
|
||||
for it in items:
|
||||
pn = it['part_number']
|
||||
if not pn:
|
||||
continue
|
||||
|
||||
# Try exact match on OEM or cross-reference
|
||||
part_id = oem_map.get(pn)
|
||||
if not part_id:
|
||||
# Try without brand prefix (e.g. "4S-86050" → "86050")
|
||||
raw = pn.split('-', 1)[1] if '-' in pn else pn
|
||||
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
|
||||
|
||||
if part_id:
|
||||
matched.append({**it, 'part_id': part_id})
|
||||
else:
|
||||
seller.append(it)
|
||||
|
||||
print(f" Matched (OEM): {len(matched):,} | Seller listings: {len(seller):,}")
|
||||
return matched, seller
|
||||
|
||||
|
||||
def sync_to_warehouse(master_conn, bodega_id, matched, seller):
|
||||
"""Bulk upsert into warehouse_inventory using COPY."""
|
||||
cur = master_conn.cursor()
|
||||
|
||||
# Create temp table matching warehouse_inventory structure
|
||||
cur.execute("""
|
||||
CREATE TEMP TABLE tmp_wi (
|
||||
user_id INT,
|
||||
part_id INT,
|
||||
seller_part_number VARCHAR(100),
|
||||
seller_part_name VARCHAR(300),
|
||||
seller_category VARCHAR(100),
|
||||
tenant_inventory_id INT,
|
||||
price NUMERIC(12,2),
|
||||
stock_quantity INT,
|
||||
min_order_quantity INT DEFAULT 1,
|
||||
warehouse_location VARCHAR(100) DEFAULT 'Principal',
|
||||
bodega_id INT,
|
||||
currency VARCHAR(3) DEFAULT 'MXN'
|
||||
) ON COMMIT DROP
|
||||
""")
|
||||
|
||||
print("[4/5] Preparing batches...")
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer, lineterminator='\n',
|
||||
quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
total = 0
|
||||
for it in matched + seller:
|
||||
part_id = it.get('part_id')
|
||||
seller_pn = None if part_id else it['part_number']
|
||||
seller_name = None if part_id else (it['name'] or it['part_number'])
|
||||
seller_cat = None if part_id else it['category']
|
||||
|
||||
writer.writerow([
|
||||
1, # user_id (legacy FK, must match existing rows for bodega 7)
|
||||
part_id, # part_id (NULL for seller listings)
|
||||
seller_pn, # seller_part_number
|
||||
seller_name, # seller_part_name
|
||||
seller_cat, # seller_category
|
||||
it['id'], # tenant_inventory_id
|
||||
it['price'], # price
|
||||
max(0, it['stock']), # stock_quantity
|
||||
1, # min_order_quantity
|
||||
'Principal', # warehouse_location
|
||||
bodega_id, # bodega_id
|
||||
'MXN', # currency
|
||||
])
|
||||
total += 1
|
||||
|
||||
if total % BATCH_SIZE == 0:
|
||||
buffer.seek(0)
|
||||
cur.copy_expert("""
|
||||
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency)
|
||||
FROM STDIN WITH (FORMAT CSV, NULL '')
|
||||
""", buffer)
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer, lineterminator='\n',
|
||||
quoting=csv.QUOTE_MINIMAL)
|
||||
print(f" Buffered {total:,} rows...")
|
||||
|
||||
# Final batch
|
||||
if buffer.tell() > 0:
|
||||
buffer.seek(0)
|
||||
cur.copy_expert("""
|
||||
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency)
|
||||
FROM STDIN WITH (FORMAT CSV, NULL '')
|
||||
""", buffer)
|
||||
|
||||
print(f"[5/5] Upserting {total:,} rows into warehouse_inventory...")
|
||||
|
||||
# --- Update existing OEM-matched rows ---
|
||||
cur.execute("""
|
||||
UPDATE warehouse_inventory wi
|
||||
SET
|
||||
price = tmp.price,
|
||||
stock_quantity = tmp.stock_quantity,
|
||||
user_id = tmp.user_id,
|
||||
currency = tmp.currency,
|
||||
updated_at = NOW()
|
||||
FROM tmp_wi tmp
|
||||
WHERE wi.bodega_id = tmp.bodega_id
|
||||
AND wi.part_id = tmp.part_id
|
||||
AND wi.warehouse_location = tmp.warehouse_location
|
||||
AND tmp.part_id IS NOT NULL
|
||||
""")
|
||||
matched_updated = cur.rowcount
|
||||
|
||||
# --- Insert new OEM-matched rows ---
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory (
|
||||
user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
|
||||
)
|
||||
SELECT
|
||||
user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
|
||||
FROM tmp_wi tmp
|
||||
WHERE part_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM warehouse_inventory wi
|
||||
WHERE wi.bodega_id = tmp.bodega_id
|
||||
AND wi.part_id = tmp.part_id
|
||||
AND wi.warehouse_location = tmp.warehouse_location
|
||||
)
|
||||
""")
|
||||
matched_inserted = cur.rowcount
|
||||
|
||||
# --- Update existing seller listings ---
|
||||
cur.execute("""
|
||||
UPDATE warehouse_inventory wi
|
||||
SET
|
||||
price = tmp.price,
|
||||
stock_quantity = tmp.stock_quantity,
|
||||
seller_part_name = tmp.seller_part_name,
|
||||
seller_category = tmp.seller_category,
|
||||
user_id = tmp.user_id,
|
||||
currency = tmp.currency,
|
||||
updated_at = NOW()
|
||||
FROM tmp_wi tmp
|
||||
WHERE wi.bodega_id = tmp.bodega_id
|
||||
AND wi.seller_part_number = tmp.seller_part_number
|
||||
AND wi.warehouse_location = tmp.warehouse_location
|
||||
AND tmp.part_id IS NULL
|
||||
""")
|
||||
seller_updated = cur.rowcount
|
||||
|
||||
# --- Insert new seller listings ---
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory (
|
||||
user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
|
||||
)
|
||||
SELECT
|
||||
user_id, part_id, seller_part_number, seller_part_name,
|
||||
seller_category, tenant_inventory_id, price, stock_quantity,
|
||||
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
|
||||
FROM tmp_wi tmp
|
||||
WHERE part_id IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM warehouse_inventory wi
|
||||
WHERE wi.bodega_id = tmp.bodega_id
|
||||
AND wi.seller_part_number = tmp.seller_part_number
|
||||
AND wi.warehouse_location = tmp.warehouse_location
|
||||
)
|
||||
""")
|
||||
seller_inserted = cur.rowcount
|
||||
|
||||
matched_upserted = matched_updated + matched_inserted
|
||||
seller_upserted = seller_updated + seller_inserted
|
||||
|
||||
master_conn.commit()
|
||||
cur.close()
|
||||
|
||||
print(f"\n✓ Done!")
|
||||
print(f" OEM matched upserted: {matched_upserted:,}")
|
||||
print(f" Seller listings upserted: {seller_upserted:,}")
|
||||
print(f" Total: {matched_upserted + seller_upserted:,}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Sync tenant inventory to marketplace')
|
||||
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
|
||||
parser.add_argument('--bodega', type=int, required=True, help='Bodega ID in master DB')
|
||||
parser.add_argument('--branch', type=int, default=1, help='Branch ID filter (default all)')
|
||||
args = parser.parse_args()
|
||||
|
||||
start = time.time()
|
||||
print(f"=== Sync tenant {args.tenant} → bodega {args.bodega} ===\n")
|
||||
|
||||
master_conn = get_master_conn()
|
||||
tenant_conn = get_tenant_conn(args.tenant)
|
||||
|
||||
try:
|
||||
oem_map, cross_map = load_catalog_maps(master_conn)
|
||||
items = read_tenant_inventory(tenant_conn, args.branch)
|
||||
matched, seller = classify_items(items, oem_map, cross_map)
|
||||
sync_to_warehouse(master_conn, args.bodega, matched, seller)
|
||||
finally:
|
||||
tenant_conn.close()
|
||||
master_conn.close()
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"\nElapsed: {elapsed:.1f}s")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
82
scripts/sync_estrada_to_marketplace.py
Normal file
82
scripts/sync_estrada_to_marketplace.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sync Strada inventory to marketplace warehouse_inventory.
|
||||
Matches by OEM part_number and cross-references.
|
||||
"""
|
||||
import os, sys, io
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
|
||||
BODEGA_ID = 7
|
||||
USER_ID = 1 # admin user in master DB
|
||||
CURRENCY = 'MXN'
|
||||
|
||||
def main():
|
||||
tenant_conn = get_tenant_conn(28)
|
||||
tenant_cur = tenant_conn.cursor()
|
||||
|
||||
# Get active inventory with stock
|
||||
tenant_cur.execute("""
|
||||
SELECT i.id, i.part_number, i.price_1, i.cost, COALESCE(iss.stock, 0) as stock
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
|
||||
WHERE i.is_active = true
|
||||
""")
|
||||
items = {r[0]: {'pn': r[1], 'price': r[2], 'cost': r[3], 'stock': r[4]} for r in tenant_cur.fetchall()}
|
||||
print(f"Active inventory: {len(items)}")
|
||||
|
||||
tenant_cur.close()
|
||||
tenant_conn.close()
|
||||
|
||||
# Get catalog mappings from master
|
||||
master = get_master_conn()
|
||||
master_cur = master.cursor()
|
||||
|
||||
master_cur.execute("SELECT id_part, oem_part_number FROM parts WHERE oem_part_number IS NOT NULL")
|
||||
oem_map = {r[1]: r[0] for r in master_cur.fetchall()}
|
||||
|
||||
master_cur.execute("SELECT part_id, cross_reference_number FROM part_cross_references")
|
||||
cross_map = {r[1]: r[0] for r in master_cur.fetchall()}
|
||||
|
||||
print(f"OEM parts: {len(oem_map)}, Cross-references: {len(cross_map)}")
|
||||
|
||||
# Build match list
|
||||
matched = []
|
||||
seen_parts = set()
|
||||
for item_id, data in items.items():
|
||||
pn = data['pn']
|
||||
raw = pn.split('-', 1)[1] if '-' in pn else pn
|
||||
|
||||
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
|
||||
if part_id and part_id not in seen_parts:
|
||||
seen_parts.add(part_id)
|
||||
price = data['price'] or data['cost'] or 0
|
||||
stock = data['stock'] or 0
|
||||
matched.append((USER_ID, part_id, price, stock, 1, 'Principal', BODEGA_ID, CURRENCY))
|
||||
|
||||
print(f"Matched items: {len(matched)}")
|
||||
|
||||
if not matched:
|
||||
print("Nothing to sync")
|
||||
return
|
||||
|
||||
# Bulk insert via COPY
|
||||
csv_buffer = io.StringIO()
|
||||
for row in matched:
|
||||
csv_buffer.write(','.join(str(c) for c in row) + '\n')
|
||||
csv_buffer.seek(0)
|
||||
|
||||
master_cur.copy_expert(
|
||||
"""COPY warehouse_inventory (user_id, part_id, price, stock_quantity, min_order_quantity, warehouse_location, bodega_id, currency)
|
||||
FROM STDIN WITH (FORMAT CSV)""",
|
||||
csv_buffer
|
||||
)
|
||||
|
||||
master.commit()
|
||||
master_cur.close()
|
||||
master.close()
|
||||
|
||||
print(f"Synced {len(matched)} items to warehouse_inventory")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
262
scripts/video_intro_inventario_pos.md
Normal file
262
scripts/video_intro_inventario_pos.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Guion de Video — Introducción a Nexus POS
|
||||
## Módulos: Inventario + Punto de Venta
|
||||
**Duración estimada:** 4–5 minutos
|
||||
**Formato:** Screen recording con voz en off
|
||||
**Resolución:** 1920×1080 (16:9)
|
||||
**Público:** Dueños de refaccionarias, mostradores, administradores
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 0 — INTRO / HOOK (0:00–0:25)
|
||||
|
||||
**Visual:**
|
||||
- Fade in desde negro.
|
||||
- Logo de Nexus Autoparts centrado.
|
||||
- Transición rápida: montaje de 3–4 tomas del sistema en acción (búsqueda de pieza, ticket imprimiéndose, gráfica de ventas).
|
||||
|
||||
**Narración (voz en off, tono energético):**
|
||||
> "¿Todavía controlas tu inventario con hojas de Excel o cuadernos?
|
||||
> Pierdes piezas, no sabes qué te queda en bodega y en la caja nunca cuadra.
|
||||
> Te presento Nexus POS: el sistema diseñado específicamente para refaccionarias.
|
||||
> En los próximos minutos vas a ver cómo administrar tu inventario y vender desde cualquier dispositivo."
|
||||
|
||||
**Nota técnica:**
|
||||
- Música de fondo: upbeat corporativo, volumen bajo durante la narración.
|
||||
- Lower third: "Nexus POS — Inventario & Punto de Venta".
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 1 — INVENTARIO: PANEL GENERAL (0:25–0:55)
|
||||
|
||||
**Visual:**
|
||||
- Login rápido (no mostrar credenciales reales, usar cuenta demo).
|
||||
- Entrar al módulo **Inventario**.
|
||||
- Mostrar el dashboard de inventario: badges superiores (Total de piezas, Stock bajo, Sin stock, Valor total).
|
||||
|
||||
**Narración:**
|
||||
> "Empecemos con el corazón de tu negocio: el inventario.
|
||||
> Desde el panel principal tienes el pulso completo de tu refaccionaria.
|
||||
> Cuántas piezas tienes, cuáles están por acabarse y el valor total de tu stock… todo al instante."
|
||||
|
||||
**Nota técnica:**
|
||||
- Usar transición suave (slide lateral) al entrar al módulo.
|
||||
- Zoom sutil sobre los badges superiores para enfatizar los números.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 2 — INVENTARIO: ALTA RÁPIDA DE PRODUCTO (0:55–1:35)
|
||||
|
||||
**Visual:**
|
||||
- Clic en "Nuevo Artículo".
|
||||
- Llenar formulario: Número de parte, nombre, marca, stock inicial, precios.
|
||||
- Destacar el campo **Código de barras** (se genera automáticamente).
|
||||
- Guardar. Aparece toast de éxito: "Artículo creado".
|
||||
|
||||
**Narración:**
|
||||
> "Dar de alta una pieza es cosa de segundos.
|
||||
> Capturas el número de parte, la descripción, la marca… y el sistema genera automáticamente un código de barras.
|
||||
> Tú decides el stock inicial y hasta tres niveles de precio.
|
||||
> Guardas… y listo, la pieza ya está en el sistema."
|
||||
|
||||
**Nota técnica:**
|
||||
- Escribir rápido pero legible (o usar autofill demo).
|
||||
- Resaltar con cursor el código de barras generado.
|
||||
- Toast de éxito debe ser visible al menos 1.5 segundos.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 3 — INVENTARIO: COMPATIBILIDAD CON VEHÍCULOS (1:35–2:05)
|
||||
|
||||
**Visual:**
|
||||
- Abrir la ficha del artículo recién creado.
|
||||
- Clic en pestaña **"Compatibilidad"**.
|
||||
- Clic en "Auto-Match con IA (QWEN)".
|
||||
- Mostrar spinner de carga breve (3–4 segundos), luego la lista de vehículos aparece (marca, modelo, año, motor).
|
||||
- Señalar la etiqueta "IA" en color naranja/azul.
|
||||
|
||||
**Narración:**
|
||||
> "Aquí viene lo potente.
|
||||
> No sabes exactamente a qué carros le queda esta pieza… pero la inteligencia artificial sí.
|
||||
> Con un solo clic, el sistema busca en segundos todos los vehículos compatibles: marca, modelo, año y motor.
|
||||
> Todo se guarda automáticamente para que tu mostrador lo consulte al instante."
|
||||
|
||||
**Nota técnica:**
|
||||
- Usar una pieza con resultados claros (ejemplo: filtro de aceite PH8A o bujía BKR5EYA).
|
||||
- Si el auto-match tarda mucho en producción, usar un clip grabado previamente o acelerar 2×.
|
||||
- Enfatizar la etiqueta "IA" con un círculo o flecha.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 4 — INVENTARIO: ENTRADAS, TRASPASOS Y AJUSTES (2:05–2:40)
|
||||
|
||||
**Visual:**
|
||||
- Volver al listado de inventario.
|
||||
- Clic en "Entrada" sobre una fila de stock (simular compra a proveedor: cantidad 10, costo $150).
|
||||
- Cambiar a pestaña **"Traspasos"**, mostrar lista de movimientos entre sucursales.
|
||||
- Cambiar a pestaña **"Ajustes"**, mostrar ajuste de inventario.
|
||||
|
||||
**Narración:**
|
||||
> "El inventario se mueve constantemente.
|
||||
> Recibiste mercancía del proveedor — registras la entrada en un clic.
|
||||
> Necesitas enviar 5 piezas a la otra sucursal — traspaso directo.
|
||||
> Y si encuentras una diferencia física contra el sistema, haces un ajuste con comentario.
|
||||
> Todo queda auditado: quién, cuándo y por qué."
|
||||
|
||||
**Nota técnica:**
|
||||
- Usar transición rápida entre pestañas (corte duro o fade).
|
||||
- Mostrar al menos 2–3 filas en cada tabla para que se vea real.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 5 — POS: APERTURA DE CAJA (2:40–3:05)
|
||||
|
||||
**Visual:**
|
||||
- Transición al módulo **Punto de Venta**.
|
||||
- Mostrar barra superior: "Sin caja abierta".
|
||||
- Clic en "Abrir Caja", llenar número de caja y monto inicial ($500).
|
||||
- Guardar. La barra cambia a "Caja #1 abierta".
|
||||
|
||||
**Narración:**
|
||||
> "Pasemos a la venta.
|
||||
> Antes de vender, abres tu caja: le pones número y efectivo inicial.
|
||||
> El sistema no te deja facturar sin caja abierta, así que siempre hay control.
|
||||
> Una vez abierta, la barra te recuerda en todo momento en qué caja estás."
|
||||
|
||||
**Nota técnica:**
|
||||
- Transición limpia: slide desde abajo o fade.
|
||||
- Close-up en la barra de estado antes y después de abrir la caja.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 6 — POS: VENTA RÁPIDA (3:05–3:45)
|
||||
|
||||
**Visual:**
|
||||
- Buscar producto por número de parte (escanear o teclear).
|
||||
- El producto aparece en el carrito con imagen, descripción y precio.
|
||||
- Agregar una segunda pieza al carrito.
|
||||
- Clic en **"Cobrar"**.
|
||||
- Modal de pago: mostrar total, cliente, descuento.
|
||||
- Dividir pago: $200 efectivo, resto transferencia.
|
||||
- Clic "Finalizar Venta". Ticket/recibo aparece en pantalla.
|
||||
|
||||
**Narración:**
|
||||
> "Vender es tan simple como buscar la pieza… puede ser por número de parte o escaneando el código de barras.
|
||||
> El carrito muestra todo claro: qué vendes, cuánto cuesta y cuánto llevas.
|
||||
> Al cobrar, aceptas efectivo, transferencia, tarjeta… o una combinación de todo.
|
||||
> Finalizas… y el ticket se genera al instante para el cliente."
|
||||
|
||||
**Nota técnica:**
|
||||
- Usar búsqueda rápida (autocomplete visible).
|
||||
- Animar el producto "volando" al carrito (si el sistema lo tiene) o simple highlight.
|
||||
- Mostrar claramente el ticket final con logo y datos de la empresa.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 7 — POS: CORTE Z Y CIERRE DE CAJA (3:45–4:10)
|
||||
|
||||
**Visual:**
|
||||
- Clic en "Corte Z" en la barra de estado.
|
||||
- Modal aparece con resumen: ventas por método de pago, entradas, salidas, efectivo esperado.
|
||||
- Ingresar efectivo real en caja ($1,247.50).
|
||||
- El sistema calcula diferencia automáticamente.
|
||||
- Clic "Cerrar Caja". Confirmación.
|
||||
|
||||
**Narración:**
|
||||
> "Al final del turno, haces tu corte.
|
||||
> El sistema te dice exactamente cuánto deberías tener en caja: ventas, entradas, salidas… todo desglosado.
|
||||
> Tú cuentas el efectivo real, lo capturas y si hay diferencia, la ves inmediatamente.
|
||||
> Cierras caja y listo: turno terminado sin sorpresas."
|
||||
|
||||
**Nota técnica:**
|
||||
- Hacer scroll dentro del modal para mostrar todos los totales.
|
||||
- Enfatizar el número de "diferencia" (positivo o negativo) con color.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 8 — CIERRE / CALL TO ACTION (4:10–4:45)
|
||||
|
||||
**Visual:**
|
||||
- Volver al dashboard principal.
|
||||
- Montaje rápido de pantallas vistas: inventario, auto-match, carrito, corte Z.
|
||||
- Fade a pantalla final: datos de contacto / sitio web / QR.
|
||||
|
||||
**Narración (tono inspirador):**
|
||||
> "Desde la primera pieza que entra hasta el último ticket del día, Nexus POS te acompaña.
|
||||
- Inventario inteligente con IA, punto de venta rápido y cortes de caja que sí cuadran.
|
||||
- Todo en un solo sistema, accesible desde tu computadora, tablet o celular.
|
||||
- Agenda tu demo hoy y lleva tu refaccionaria al siguiente nivel."
|
||||
|
||||
**Texto en pantalla (overlay):**
|
||||
- "¿Listo para modernizar tu refaccionaria?"
|
||||
- Botón/CTA: "Agenda tu demo gratis"
|
||||
- Web: nexusautoparts.com.mx
|
||||
- WhatsApp: [número]
|
||||
|
||||
**Nota técnica:**
|
||||
- Música sube de volumen en la pantalla final.
|
||||
- Logo + CTA deben permanecer 5–7 segundos.
|
||||
|
||||
---
|
||||
|
||||
## CHECKLIST TÉCNICO PARA GRABACIÓN
|
||||
|
||||
| Ítem | Especificación |
|
||||
|------|----------------|
|
||||
| **Resolución** | 1920×1080 mínimo |
|
||||
| **FPS** | 30 fps (60 fps opcional para animaciones suaves) |
|
||||
| **Audio** | Voz en off clara, sin eco. Música royalty-free bajo |
|
||||
| **Cursor** | Usar cursor grande y resaltado (Halo o similar) |
|
||||
| **Transiciones** | Slide lateral 0.3s o fade 0.2s. Nada exagerado |
|
||||
| **Datos demo** | Usar piezas reales: BKR5EYA, PH8A, BP6ES. Cliente: "Juan Pérez" |
|
||||
| **Cuenta** | Usar usuario demo; NUNCA mostrar contraseñas reales |
|
||||
| **Limpieza** | Cerrar notificaciones del OS antes de grabar |
|
||||
|
||||
---
|
||||
|
||||
## DATOS DE PRUEBA RECOMENDADOS PARA LA GRABACIÓN
|
||||
|
||||
### Producto 1 (alta en vivo)
|
||||
- Número de parte: `PH8A`
|
||||
- Nombre: `Filtro de aceite Motorcraft`
|
||||
- Marca: `Motorcraft`
|
||||
- Stock inicial: `12`
|
||||
- Precio 1: `$185.00`
|
||||
|
||||
### Producto 2 (venta rápida)
|
||||
- Número de parte: `BKR5EYA`
|
||||
- Nombre: `Bujía NGK`
|
||||
- Precio: `$145.00`
|
||||
|
||||
### Cliente demo
|
||||
- Nombre: `Juan Pérez`
|
||||
- Teléfono: `555-123-4567`
|
||||
|
||||
### Caja
|
||||
- Caja #1
|
||||
- Apertura: `$500.00`
|
||||
- Cierre: `$1,247.50`
|
||||
|
||||
---
|
||||
|
||||
## NOTAS POST-PRODUCCIÓN
|
||||
|
||||
1. **Subtítulos:** Agregar subtítulos en español (accesibilidad + redes sociales sin audio).
|
||||
2. **Capítulos:** YouTube chapters recomendados:
|
||||
- 0:00 Intro
|
||||
- 0:25 Inventario
|
||||
- 0:55 Alta de producto
|
||||
- 1:35 Compatibilidad con IA
|
||||
- 2:05 Movimientos de inventario
|
||||
- 2:40 Punto de Venta
|
||||
- 3:05 Venta rápida
|
||||
- 3:45 Corte de caja
|
||||
- 4:10 Demo y contacto
|
||||
3. **Thumbnail:** Split screen — mitad inventario, mitad POS. Texto: "Control total de tu refaccionaria".
|
||||
4. **Formatos de exportación:**
|
||||
- MP4 H.264 (YouTube / web)
|
||||
- Vertical 9:16 recorte para Reels/TikTok (60 seg version)
|
||||
|
||||
---
|
||||
|
||||
*Guion generado para Nexus Autoparts — Módulos Inventario & POS*
|
||||
*Versión 1.0 — 2026-05-18*
|
||||
Reference in New Issue
Block a user