- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
304 lines
12 KiB
Python
304 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Attach images to inventory items that are (or will be) published on MeLi.
|
|
|
|
Fixes publishing block #1: ML requires at least one picture, and Rached (and
|
|
most tenants) have zero rows in inventory_images, so publish_items rejects
|
|
every item with "El producto no tiene imagen".
|
|
|
|
Image sources, in priority order:
|
|
1. local — files already on disk for this tenant:
|
|
/home/Autopartes/pos/static/images/parts/<tenant>_<inventory_id>.jpg
|
|
/home/Autopartes/data/images/parts/<tenant>/<inventory_id>_full.webp
|
|
2. catalog — master supplier_catalog.image_url, joined by part number / SKU
|
|
3. ml — pictures of the MercadoLibre listing already linked to the item
|
|
(downloaded and re-hosted locally with services.image_service, or, with
|
|
--remote, registered as the ML secure_url directly)
|
|
|
|
NOTE on the data/ Excel catalogs (VAZLO, YOKOMITSU, KNADIAN, LUK, RAYBESTOS,
|
|
KG, PRODUCTOS_RACHED_2026): they were checked and contain NO image columns
|
|
(marca / sku / INTERCAMBIO / nombre / carro only), so they cannot supply
|
|
images. Also, master supplier_catalog.image_url is empty for all 17k rows
|
|
today, so the catalog source is currently a no-op.
|
|
|
|
Registered images go to inventory_images (sort_order + is_primary; the first
|
|
becomes primary) and inventory.image_url is set to the primary one.
|
|
|
|
Usage (from /home/Autopartes):
|
|
python3 scripts/meli_attach_images.py --tenant 31 # dry-run
|
|
python3 scripts/meli_attach_images.py --tenant 31 --apply
|
|
python3 scripts/meli_attach_images.py --tenant 31 --apply --remote
|
|
python3 scripts/meli_attach_images.py --tenant 31 --apply --limit 5
|
|
python3 scripts/meli_attach_images.py --tenant 31 --apply --inventory-id 4752
|
|
python3 scripts/meli_attach_images.py --tenant 31 --json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
|
|
|
|
STATIC_PARTS_DIR = '/home/Autopartes/pos/static/images/parts'
|
|
DATA_PARTS_DIR = '/home/Autopartes/data/images/parts'
|
|
MELI_MAX_PICTURES = 10
|
|
|
|
|
|
def _load_env():
|
|
env_file = '/etc/nexus/pos.env'
|
|
if os.environ.get('MASTER_DB_URL') or not os.path.exists(env_file):
|
|
return
|
|
for line in open(env_file):
|
|
line = line.strip()
|
|
if not line or line.startswith('#') or '=' not in line:
|
|
continue
|
|
key, value = line.split('=', 1)
|
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
|
|
|
|
def _local_urls(tenant_id, inventory_id):
|
|
"""Images already on disk for this tenant/item (relative, servable URLs)."""
|
|
urls = []
|
|
scraper = os.path.join(STATIC_PARTS_DIR, f'{tenant_id}_{inventory_id}.jpg')
|
|
if os.path.exists(scraper):
|
|
urls.append(f'/pos/static/images/parts/{tenant_id}_{inventory_id}.jpg')
|
|
processed = os.path.join(STATIC_PARTS_DIR, str(tenant_id), f'{inventory_id}_full.webp')
|
|
if os.path.exists(processed):
|
|
urls.append(f'/pos/static/images/parts/{tenant_id}/{inventory_id}_full.webp')
|
|
return urls
|
|
|
|
|
|
def _catalog_urls(master_cur, skus):
|
|
"""supplier_catalog.image_url for any of the given SKUs (non-empty only)."""
|
|
keys = [s for s in (skus or []) if s]
|
|
if not keys:
|
|
return []
|
|
master_cur.execute(
|
|
"""
|
|
SELECT DISTINCT image_url FROM supplier_catalog
|
|
WHERE image_url IS NOT NULL AND image_url <> ''
|
|
AND UPPER(sku) = ANY(%s)
|
|
LIMIT %s
|
|
""",
|
|
([str(k).strip().upper() for k in keys], MELI_MAX_PICTURES),
|
|
)
|
|
return [r[0] for r in master_cur.fetchall()]
|
|
|
|
|
|
def _ml_urls(svc, tenant_id, inventory_id, external_item_id, remote, apply=True):
|
|
"""Pictures of the ML listing. Returns candidate URLs for inventory_images.
|
|
|
|
The first picture is downloaded and re-hosted locally with
|
|
services.image_service (that service stores one file per item, so the
|
|
primary is self-contained); the remaining pictures keep their ML-hosted
|
|
secure_url, which is public and valid for ML itself.
|
|
|
|
In dry-run (apply=False) nothing is downloaded: secure_urls are returned
|
|
as candidates so the caller can report without side effects.
|
|
"""
|
|
from services import image_service
|
|
pictures = (svc.get_item(str(external_item_id)).get('pictures') or [])
|
|
urls = []
|
|
for index, pic in enumerate(pictures[:MELI_MAX_PICTURES]):
|
|
remote_url = pic.get('secure_url') or pic.get('url')
|
|
if not remote_url:
|
|
continue
|
|
if remote or not apply or index > 0:
|
|
urls.append(remote_url)
|
|
continue
|
|
try:
|
|
saved = image_service.save_image(tenant_id, inventory_id, image_url=remote_url)
|
|
urls.append(saved['image_url'])
|
|
except Exception as e:
|
|
print(f" ! could not download {remote_url}: {e}", flush=True)
|
|
urls.append(remote_url) # fall back to the ML-hosted URL
|
|
return urls
|
|
|
|
|
|
def _register_images(cur, inventory_id, urls, apply):
|
|
"""Insert new inventory_images rows (first becomes primary) + set image_url."""
|
|
urls = [u for u in dict.fromkeys(urls) if u] # dedupe, keep order
|
|
if not urls:
|
|
return 0
|
|
|
|
cur.execute(
|
|
"SELECT url, is_primary FROM inventory_images WHERE inventory_id = %s "
|
|
"ORDER BY is_primary DESC, sort_order, id",
|
|
(inventory_id,),
|
|
)
|
|
existing = cur.fetchall()
|
|
have = {r[0] for r in existing}
|
|
has_primary = any(r[1] for r in existing)
|
|
order = len(existing)
|
|
|
|
added = 0
|
|
for url in urls:
|
|
if url in have:
|
|
continue
|
|
is_primary = (not has_primary and added == 0)
|
|
if apply:
|
|
cur.execute(
|
|
"INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary) "
|
|
"VALUES (%s, %s, %s, %s)",
|
|
(inventory_id, url, order, is_primary),
|
|
)
|
|
have.add(url)
|
|
added += 1
|
|
order += 1
|
|
if is_primary:
|
|
has_primary = True
|
|
|
|
if apply and added:
|
|
cur.execute(
|
|
"SELECT url FROM inventory_images WHERE inventory_id = %s "
|
|
"ORDER BY is_primary DESC, sort_order, id LIMIT 1",
|
|
(inventory_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (row[0], inventory_id))
|
|
return added
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--tenant', type=int, required=True, help='tenant id (e.g. 31 = Rached)')
|
|
ap.add_argument('--apply', action='store_true', help='write to inventory_images (default: dry-run)')
|
|
ap.add_argument('--source', choices=['auto', 'local', 'catalog', 'ml'], default='auto',
|
|
help='restrict to one source (default: auto = first available)')
|
|
ap.add_argument('--remote', action='store_true',
|
|
help='register the MeLi secure_url directly instead of downloading it')
|
|
ap.add_argument('--limit', type=int, default=None, help='max items to process')
|
|
ap.add_argument('--inventory-id', type=int, action='append', dest='inventory_ids',
|
|
help='only this inventory id (repeatable)')
|
|
ap.add_argument('--json', action='store_true', help='print the summary as JSON')
|
|
args = ap.parse_args()
|
|
|
|
_load_env()
|
|
|
|
from tenant_db import get_tenant_conn, get_master_conn
|
|
from services import marketplace_external_service as meli
|
|
|
|
conn = get_tenant_conn(args.tenant)
|
|
master = get_master_conn()
|
|
|
|
try:
|
|
cur = conn.cursor()
|
|
cfg = meli.get_meli_config(conn)
|
|
svc = meli._get_meli_service(cfg, conn)
|
|
|
|
# Targets: explicit inventory ids, or every mapped ML listing.
|
|
if args.inventory_ids:
|
|
cur.execute(
|
|
"""
|
|
SELECT inventory_id, MAX(external_item_id)
|
|
FROM marketplace_listings
|
|
WHERE channel = 'mercadolibre' AND inventory_id = ANY(%s)
|
|
GROUP BY inventory_id
|
|
ORDER BY inventory_id
|
|
""",
|
|
(args.inventory_ids,),
|
|
)
|
|
targets = cur.fetchall()
|
|
# Inventory ids with no listing yet still deserve an image.
|
|
seen = {t[0] for t in targets}
|
|
targets += [(iid, None) for iid in args.inventory_ids if iid not in seen]
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT inventory_id, MAX(external_item_id)
|
|
FROM marketplace_listings
|
|
WHERE channel = 'mercadolibre' AND inventory_id IS NOT NULL
|
|
GROUP BY inventory_id
|
|
ORDER BY inventory_id
|
|
"""
|
|
)
|
|
targets = cur.fetchall()
|
|
|
|
if args.limit:
|
|
targets = targets[:args.limit]
|
|
|
|
master_cur = master.cursor()
|
|
summary = {"tenant": args.tenant, "dry_run": not args.apply,
|
|
"targets": len(targets), "with_images": 0, "no_image": 0,
|
|
"images_added": 0, "sources": {"local": 0, "catalog": 0, "ml": 0},
|
|
"no_image_items": [], "details": []}
|
|
|
|
if not targets:
|
|
print("No targets: no marketplace listing is linked to inventory yet.")
|
|
return 0
|
|
|
|
for inventory_id, external_item_id in targets:
|
|
cur.execute(
|
|
"SELECT part_number FROM inventory WHERE id = %s", (inventory_id,)
|
|
)
|
|
row = cur.fetchone()
|
|
part_number = row[0] if row else None
|
|
|
|
urls, used = [], None
|
|
want = lambda s: args.source in ('auto', s) # noqa: E731
|
|
|
|
if want('local'):
|
|
local = _local_urls(args.tenant, inventory_id)
|
|
if local:
|
|
urls, used = local, 'local'
|
|
if not urls and want('catalog'):
|
|
catalog = _catalog_urls(master_cur, [part_number])
|
|
if catalog:
|
|
urls, used = catalog, 'catalog'
|
|
if not urls and want('ml') and external_item_id and svc:
|
|
try:
|
|
ml = _ml_urls(svc, args.tenant, inventory_id, external_item_id,
|
|
args.remote, apply=args.apply)
|
|
if ml:
|
|
urls, used = ml, 'ml'
|
|
except Exception as e:
|
|
print(f" [{inventory_id}] ML image fetch failed: {e}", flush=True)
|
|
|
|
if not urls:
|
|
summary['no_image'] += 1
|
|
summary['no_image_items'].append(inventory_id)
|
|
summary['details'].append({
|
|
"inventory_id": inventory_id, "part_number": part_number,
|
|
"status": "no_image",
|
|
})
|
|
continue
|
|
|
|
added = _register_images(cur, inventory_id, urls, args.apply)
|
|
if args.apply:
|
|
conn.commit()
|
|
summary['with_images'] += 1
|
|
summary['images_added'] += added
|
|
summary['sources'][used] = summary['sources'].get(used, 0) + 1
|
|
summary['details'].append({
|
|
"inventory_id": inventory_id, "part_number": part_number,
|
|
"status": "ok", "source": used, "found": len(urls), "added": added,
|
|
})
|
|
|
|
master_cur.close()
|
|
cur.close()
|
|
finally:
|
|
master.close()
|
|
conn.close()
|
|
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2, ensure_ascii=False, default=str))
|
|
return 0
|
|
|
|
mode = 'DRY-RUN (no writes)' if summary['dry_run'] else 'APPLIED'
|
|
print(f"[tenant {args.tenant}] attach images — {mode}")
|
|
print(f" targets: {summary['targets']}")
|
|
print(f" with images: {summary['with_images']} (by source: {summary['sources']})")
|
|
print(f" images added: {summary['images_added']}")
|
|
print(f" no image found: {summary['no_image']}")
|
|
if summary['no_image_items']:
|
|
print(f" inventory ids without a usable image: {summary['no_image_items']}")
|
|
if summary['dry_run'] and summary['images_added'] >= 0:
|
|
print("\n Re-run with --apply to persist the images in inventory_images.")
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|