Files
Autoparts-DB/pos/services/yonke_service.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

1699 lines
62 KiB
Python

# /home/Autopartes/pos/services/yonke_service.py
"""Nexus Yonke service: used / rebuilt parts, donor vehicles and consignments.
Every function receives an open tenant connection (psycopg2) and does NOT
commit: the calling blueprint owns the transaction. Stock is never stored as a
column — it is read from inventory_stock / inventory_stock_summary via
services.inventory_engine.
Schema (M1 migration, applied to all tenants):
inventory.condition / condition_grade / is_unique_piece / serial_number /
donor_* / ownership / acquisition_cost
inventory_images, consignments, consignment_items
"""
import os
import re
import uuid
from psycopg2 import errors as pg_errors
from services.audit import log_action
from services.inventory_engine import record_initial
from services.image_service import save_image, DATA_DIR, STATIC_DIR
# Allowed values (mirror the CHECK constraints from the M1 migration).
CONDITIONS = ('nuevo', 'usado', 'reconstruido')
USED_CONDITIONS = ('usado', 'reconstruido')
GRADES = ('A', 'B', 'C', 'D')
OWNERSHIPS = ('propio', 'consigna')
# Columns returned for a piece, in strict order (see _row_to_item). The stock
# column is always appended LAST by every SELECT built in this module.
_PIECE_FIELDS = [
'id', 'part_number', 'name', 'brand', 'condition', 'condition_grade',
'is_unique_piece', 'donor_vin', 'donor_make', 'donor_model', 'donor_year',
'donor_mileage_km', 'ownership', 'price_1', 'cost', 'location', 'image_url',
'serial_number',
]
_SELECT_PIECE = ", ".join("i." + f for f in _PIECE_FIELDS)
_EDITABLE_FIELDS = {
'name': 'name',
'condition': 'condition',
'condition_grade': 'condition_grade',
'grade': 'condition_grade',
'is_unique_piece': 'is_unique_piece',
'serial_number': 'serial_number',
'donor_vin': 'donor_vin',
'donor_make': 'donor_make',
'donor_model': 'donor_model',
'donor_year': 'donor_year',
'donor_mileage_km': 'donor_mileage_km',
'ownership': 'ownership',
'location': 'location',
}
_UPPER_TEXT_FIELDS = ('donor_vin', 'donor_make', 'donor_model', 'serial_number')
class DuplicatePartNumber(Exception):
"""Raised when a piece is created with a part_number that already exists."""
# ─── small coercion helpers (same semantics as inventory_bp) ────────────────
def _to_float(val, default=0):
if val is None or val == '':
return default
try:
return float(str(val).replace(',', ''))
except (TypeError, ValueError):
return default
def _to_int(val, default=None):
if val is None or val == '':
return default
try:
return int(float(str(val).replace(',', '')))
except (TypeError, ValueError):
return default
def _tier_prices(conn, price_1):
"""Return (price_2, price_3) derived from the tenant's global tier discounts."""
p1 = _to_float(price_1, 0)
discounts = {}
try:
cur = conn.cursor()
cur.execute("SELECT tier_id, discount_pct FROM tier_discounts")
discounts = {r[0]: float(r[1] or 0) for r in cur.fetchall()}
cur.close()
except Exception:
try:
conn.rollback()
except Exception:
pass
discounts = {}
p2 = round(p1 * (1 - discounts.get(2, 0) / 100), 2)
p3 = round(p1 * (1 - discounts.get(3, 0) / 100), 2)
return p2, p3
def _images_for(conn, inventory_ids):
"""Bulk-load inventory_images for a set of inventory ids.
Returns {inventory_id: [{id, url, is_primary, sort_order}, ...]}.
"""
if not inventory_ids:
return {}
cur = conn.cursor()
cur.execute("""
SELECT id, inventory_id, url, is_primary, sort_order
FROM inventory_images
WHERE inventory_id = ANY(%s)
ORDER BY inventory_id, sort_order, id
""", (list(inventory_ids),))
rows = cur.fetchall()
cur.close()
out = {}
for r in rows:
out.setdefault(r[1], []).append({
'id': r[0], 'url': r[2], 'is_primary': r[3], 'sort_order': r[4],
})
return out
def _row_to_item(row):
"""Map a SELECT built with _SELECT_PIECE + stock (last col) to a dict."""
item = {}
for idx, field in enumerate(_PIECE_FIELDS):
item[field] = row[idx]
item['price_1'] = float(item['price_1'] or 0)
item['cost'] = float(item['cost'] or 0)
item['stock'] = row[len(_PIECE_FIELDS)] or 0
item['images'] = []
return item
def _sku_token(donor_vin):
"""Last 6 chars of the donor VIN, or 'SN' when the VIN is missing."""
vin = (donor_vin or '').strip().upper()
return vin[-6:] if vin else 'SN'
def generate_unique_sku(conn, donor_vin):
"""Build a unique auto-SKU: ``USADO-<last 6 of VIN | SN>-<sequence>``.
The sequence is max(existing numeric suffix) + 1 for the same prefix.
Uniqueness is verified against inventory.part_number (the UNIQUE index).
"""
prefix = f"USADO-{_sku_token(donor_vin)}-"
cur = conn.cursor()
cur.execute(
"SELECT part_number FROM inventory WHERE part_number LIKE %s",
(prefix + '%',),
)
max_seq = 0
for (pn,) in cur.fetchall():
m = re.match(re.escape(prefix) + r'(\d+)$', pn or '')
if m:
max_seq = max(max_seq, int(m.group(1)))
next_seq = max_seq + 1
while True:
candidate = f"{prefix}{next_seq}"
cur.execute("SELECT 1 FROM inventory WHERE part_number = %s", (candidate,))
if not cur.fetchone():
break
next_seq += 1
cur.close()
return candidate
# ─── Piece listing / retrieval ─────────────────────────────────────────────
def _piece_where(filters):
"""Build (where_sql, params) for the piece list, applying all Yonke filters."""
filters = filters or {}
where = ["i.is_active = true"]
params = []
condition = (filters.get('condition') or '').strip().lower()
if condition:
if condition not in CONDITIONS:
raise ValueError(f"condition inválida: {condition}")
where.append("i.condition = %s")
params.append(condition)
else:
# Default: all used/rebuilt pieces.
where.append("i.condition IN ('usado', 'reconstruido')")
q = (filters.get('q') or '').strip()
if q:
where.append(
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s "
"OR i.donor_vin ILIKE %s OR i.serial_number ILIKE %s)"
)
params.extend([f'%{q}%'] * 5)
for key, col in (('donor_make', 'i.donor_make'), ('donor_model', 'i.donor_model')):
val = (filters.get(key) or '').strip()
if val:
where.append(f"{col} ILIKE %s")
params.append(f'%{val}%')
year = _to_int(filters.get('donor_year'))
if year is not None:
where.append("i.donor_year = %s")
params.append(year)
vin = (filters.get('vin') or '').strip()
if vin:
where.append("i.donor_vin ILIKE %s")
params.append(f'%{vin}%')
grade = (filters.get('grade') or '').strip().upper()
if grade:
if grade not in GRADES:
raise ValueError(f"grade inválido: {grade}")
where.append("i.condition_grade = %s")
params.append(grade)
ownership = (filters.get('ownership') or '').strip().lower()
if ownership:
if ownership not in OWNERSHIPS:
raise ValueError(f"ownership inválido: {ownership}")
where.append("i.ownership = %s")
params.append(ownership)
return " AND ".join(where), params
def _stock_join(branch_id):
"""Return (join_sql, params, select_expr) for per-branch or global stock."""
if branch_id:
return (
"LEFT JOIN inventory_stock ist ON ist.inventory_id = i.id AND ist.branch_id = %s",
[int(branch_id)],
"COALESCE(ist.stock, 0)",
)
return (
"LEFT JOIN inventory_stock_summary ist ON ist.inventory_id = i.id",
[],
"COALESCE(ist.stock, 0)",
)
def list_pieces(conn, filters=None, page=1, per_page=25, branch_id=None):
"""List yonke pieces. Returns (items, pagination_dict)."""
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 25) or 25, 1), 200)
where_sql, params = _piece_where(filters)
join_sql, join_params, stock_expr = _stock_join(branch_id)
cur = conn.cursor()
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where_sql}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT {_SELECT_PIECE}, {stock_expr}
FROM inventory i
{join_sql}
WHERE {where_sql}
ORDER BY i.created_at DESC, i.id DESC
LIMIT %s OFFSET %s
""", join_params + params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = [_row_to_item(r) for r in rows]
images = _images_for(conn, [it['id'] for it in items])
for it in items:
it['images'] = images.get(it['id'], [])
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return items, pagination
def get_piece(conn, piece_id, branch_id=None):
"""Return one piece (with stock + images) or None."""
join_sql, join_params, stock_expr = _stock_join(branch_id)
cur = conn.cursor()
cur.execute(f"""
SELECT {_SELECT_PIECE}, {stock_expr}
FROM inventory i
{join_sql}
WHERE i.id = %s
""", join_params + [piece_id])
row = cur.fetchone()
cur.close()
if not row:
return None
item = _row_to_item(row)
item['images'] = _images_for(conn, [piece_id]).get(piece_id, [])
return item
# ─── Piece creation / edition ──────────────────────────────────────────────
def _auto_category_id(conn, part_number, name, brand=None):
"""Best-effort category pre-fill using services.product_categorizer.
Uses the rule engine (``classify_name``) plus the tenant's existing
categories. A brand new category is inserted inside the CALLER's
transaction — ``CategoryResolver.resolve(create=True)`` commits internally
and would break the caller's atomicity, so it is deliberately not used here.
Returns a categories.id or None; never raises.
"""
cur = None
try:
from services import product_categorizer as pc
canonical, _confidence, _source = pc.classify_name(name or part_number, brand)
if not canonical:
return None
resolver = pc.CategoryResolver(conn, create=False)
if not resolver.available:
return None
cid, _cname = resolver.resolve(canonical)
if cid is not None:
return cid
cur = conn.cursor()
cur.execute("SAVEPOINT yonke_autocat")
try:
cur.execute(
"INSERT INTO categories (name) VALUES (%s) RETURNING id",
(canonical,),
)
new_id = cur.fetchone()[0]
except Exception:
cur.execute("ROLLBACK TO SAVEPOINT yonke_autocat")
return None
cur.execute("RELEASE SAVEPOINT yonke_autocat")
return new_id
except Exception:
return None
finally:
if cur is not None:
try:
cur.close()
except Exception:
pass
def create_piece(conn, data, branch_id=None):
"""Create a used/rebuild piece. Raises ValueError (400) or
DuplicatePartNumber (409). Returns the created item."""
data = data or {}
name = (data.get('name') or '').strip()
if not name:
raise ValueError('name required')
condition = (data.get('condition') or 'usado').strip().lower()
if condition not in USED_CONDITIONS:
raise ValueError("condition debe ser 'usado' o 'reconstruido'")
grade = (data.get('condition_grade') or data.get('grade') or '').strip().upper() or None
if grade and grade not in GRADES:
raise ValueError('condition_grade debe ser A, B, C o D')
ownership = (data.get('ownership') or 'propio').strip().lower()
if ownership not in OWNERSHIPS:
raise ValueError("ownership debe ser 'propio' o 'consigna'")
consignment_id = _to_int(data.get('consignment_id'))
if consignment_id:
ownership = 'consigna'
donor_vin = (data.get('donor_vin') or '').strip().upper() or None
donor_make = (data.get('donor_make') or '').strip() or None
donor_model = (data.get('donor_model') or '').strip() or None
serial_number = (data.get('serial_number') or '').strip() or None
category_id = _to_int(data.get('category_id'))
part_number = (data.get('part_number') or '').strip() or None
auto_sku = part_number is None
if auto_sku:
part_number = generate_unique_sku(conn, donor_vin)
else:
cur = conn.cursor()
cur.execute("SELECT 1 FROM inventory WHERE part_number = %s", (part_number,))
exists = cur.fetchone()
cur.close()
if exists:
raise DuplicatePartNumber(part_number)
price_1 = _to_float(data.get('price_1'), 0)
cost = _to_float(data.get('cost'), 0)
acquisition_cost = _to_float(data.get('acquisition_cost', cost), cost)
price_2, price_3 = _tier_prices(conn, price_1)
image_url = data.get('image_url') or None
inv_id = None
for attempt in range(5):
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO inventory
(part_number, name, brand, unit, cost, price_1, price_2, price_3, tax_rate,
condition, condition_grade, is_unique_piece, serial_number,
donor_vin, donor_make, donor_model, donor_year, donor_mileage_km,
ownership, acquisition_cost, location, image_url, category_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,
%s,%s,%s,%s,
%s,%s,%s,%s,%s,
%s,%s,%s,%s, %s)
RETURNING id
""", (
part_number, name, data.get('brand'), data.get('unit', 'PZA'),
cost, price_1, price_2, price_3, data.get('tax_rate', 0.16),
condition, grade, bool(data.get('is_unique_piece', True)), serial_number,
donor_vin, donor_make, donor_model,
_to_int(data.get('donor_year')), _to_int(data.get('donor_mileage_km')),
ownership, acquisition_cost, data.get('location'), image_url, category_id,
))
inv_id = cur.fetchone()[0]
break
except pg_errors.UniqueViolation:
conn.rollback()
if not auto_sku:
raise DuplicatePartNumber(part_number)
if attempt == 4:
raise
part_number = generate_unique_sku(conn, donor_vin)
finally:
cur.close()
# Best-effort auto-categorization, AFTER the insert so an SKU-retry
# rollback cannot leave a dangling category_id. Never blocks creation.
if category_id is None:
category_id = _auto_category_id(conn, part_number, name, data.get('brand'))
if category_id is not None:
cur = conn.cursor()
cur.execute("UPDATE inventory SET category_id = %s WHERE id = %s",
(category_id, inv_id))
cur.close()
initial_stock = _to_int(data.get('initial_stock'), 1) or 0
if initial_stock > 0 and branch_id:
record_initial(conn, inv_id, branch_id, initial_stock, cost)
if image_url:
cur = conn.cursor()
cur.execute("""
INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary)
VALUES (%s, %s, 0, true)
""", (inv_id, image_url))
cur.close()
if consignment_id:
cur = conn.cursor()
cur.execute("SELECT commission_pct FROM consignments WHERE id = %s", (consignment_id,))
row = cur.fetchone()
if not row:
cur.close()
raise ValueError(f'consignment {consignment_id} not found')
commission = float(row[0] or 0)
cur.execute("""
INSERT INTO consignment_items
(consignment_id, inventory_id, agreed_price, commission_amount, status)
VALUES (%s, %s, %s, %s, 'in_stock')
""", (consignment_id, inv_id, price_1,
round(price_1 * commission / 100, 2)))
cur.close()
# warranty_days is NOT registered here (only used at sale time); it is kept
# as a tenant-wide default for the yonke module.
warranty_days = _to_int(data.get('warranty_days'))
if warranty_days:
_store_default_warranty_days(conn, warranty_days)
log_action(conn, 'YONKE_PIECE_CREATE', 'inventory', inv_id,
new_value={'part_number': part_number, 'name': name,
'condition': condition, 'ownership': ownership,
'donor_vin': donor_vin})
return get_piece(conn, inv_id, branch_id)
def _store_default_warranty_days(conn, days):
"""Persist the yonke default warranty (days) in tenant_config."""
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value, updated_at)
VALUES ('yonke_default_warranty_days', %s, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""", (str(int(days)),))
cur.close()
def _coerce_edit_value(col, val):
if col == 'condition':
val = (val or '').strip().lower()
if val not in CONDITIONS:
raise ValueError(f"condition inválida: {val}")
return val
if col == 'condition_grade':
val = (val or '').strip().upper() or None
if val and val not in GRADES:
raise ValueError('condition_grade debe ser A, B, C o D')
return val
if col == 'ownership':
val = (val or '').strip().lower()
if val not in OWNERSHIPS:
raise ValueError(f"ownership inválido: {val}")
return val
if col == 'is_unique_piece':
return bool(val)
if col in ('donor_year', 'donor_mileage_km'):
return _to_int(val)
if col == 'name':
val = (val or '').strip()
if not val:
raise ValueError('name cannot be empty')
return val
if col in _UPPER_TEXT_FIELDS:
val = (val or '').strip().upper() or None
return val
# location and remaining free-text columns
return (str(val).strip() or None) if val is not None else None
def update_piece(conn, piece_id, data, branch_id=None):
"""Update the Yonke fields of a piece. Returns the item or None (404)."""
data = data or {}
cur = conn.cursor()
cur.execute("SELECT id FROM inventory WHERE id = %s", (piece_id,))
if not cur.fetchone():
cur.close()
return None
cur.close()
updates = {}
for key, col in _EDITABLE_FIELDS.items():
if key in data:
updates[col] = _coerce_edit_value(col, data.get(key))
if 'price_1' in data:
p1 = _to_float(data.get('price_1'), 0)
updates['price_1'] = p1
p2, p3 = _tier_prices(conn, p1)
updates['price_2'] = p2
updates['price_3'] = p3
if 'cost' in data:
c = _to_float(data.get('cost'), 0)
updates['cost'] = c
updates['acquisition_cost'] = c
if 'acquisition_cost' in data:
updates['acquisition_cost'] = _to_float(data.get('acquisition_cost'), 0)
if not updates:
return get_piece(conn, piece_id, branch_id)
set_sql = ", ".join(f"{col} = %s" for col in updates)
cur = conn.cursor()
cur.execute(
f"UPDATE inventory SET {set_sql} WHERE id = %s",
list(updates.values()) + [piece_id],
)
cur.close()
log_action(conn, 'YONKE_PIECE_UPDATE', 'inventory', piece_id,
new_value={k: (str(v) if v is not None else None) for k, v in updates.items()})
return get_piece(conn, piece_id, branch_id)
# ─── Images ────────────────────────────────────────────────────────────────
def _delete_files_for_url(tenant_id, url):
"""Remove the full + thumb files backing an inventory_images.url."""
if not url:
return
filename = url.rstrip('/').split('/')[-1]
base = re.sub(r'_(full|thumb)\.webp$', '', filename)
for directory in (os.path.join(DATA_DIR, str(tenant_id)),
os.path.join(STATIC_DIR, str(tenant_id))):
for suffix in ('_full.webp', '_thumb.webp'):
path = os.path.join(directory, base + suffix)
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
def add_piece_images(conn, tenant_id, piece_id, files):
"""Save uploaded files for a piece, one inventory_images row each.
The first image of the piece becomes the primary one and its URL is copied
to inventory.image_url. Returns the list of created image rows.
"""
cur = conn.cursor()
cur.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM inventory_images WHERE inventory_id = %s",
(piece_id,),
)
next_order = cur.fetchone()[0]
cur.execute(
"SELECT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_id = %s AND is_primary)",
(piece_id,),
)
has_primary = cur.fetchone()[0]
cur.close()
saved = []
for file_obj in files:
# Unique basename per photo so save_image() does not overwrite others.
token = f"{piece_id}_{uuid.uuid4().hex[:10]}"
result = save_image(tenant_id, token, file_obj=file_obj)
url = result['image_url']
is_primary = (not has_primary) and (not saved)
cur = conn.cursor()
cur.execute("""
INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary)
VALUES (%s, %s, %s, %s)
RETURNING id
""", (piece_id, url, next_order, is_primary))
img_id = cur.fetchone()[0]
if is_primary:
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (url, piece_id))
cur.close()
saved.append({'id': img_id, 'url': url,
'is_primary': is_primary, 'sort_order': next_order})
next_order += 1
has_primary = has_primary or is_primary
log_action(conn, 'YONKE_IMAGE_ADD', 'inventory', piece_id,
new_value={'count': len(saved)})
return saved
def delete_piece_image(conn, tenant_id, piece_id, image_id):
"""Delete one image row + its files. Promotes another image if it was primary.
Returns False when the image does not belong to the piece.
"""
cur = conn.cursor()
cur.execute(
"SELECT url, is_primary FROM inventory_images WHERE id = %s AND inventory_id = %s",
(image_id, piece_id),
)
row = cur.fetchone()
if not row:
cur.close()
return False
url, was_primary = row
cur.execute("DELETE FROM inventory_images WHERE id = %s", (image_id,))
if was_primary:
cur.execute("""
SELECT id, url FROM inventory_images
WHERE inventory_id = %s ORDER BY sort_order, id LIMIT 1
""", (piece_id,))
nxt = cur.fetchone()
if nxt:
cur.execute("UPDATE inventory_images SET is_primary = true WHERE id = %s", (nxt[0],))
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (nxt[1], piece_id))
else:
cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (piece_id,))
cur.close()
_delete_files_for_url(tenant_id, url)
log_action(conn, 'YONKE_IMAGE_DELETE', 'inventory', piece_id, old_value={'url': url})
return True
# ─── Donors ────────────────────────────────────────────────────────────────
def list_donors(conn, q=None, page=1, per_page=25):
"""Group yonke pieces by donor VIN. Returns (data, pagination)."""
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 25) or 25, 1), 200)
where = ["i.donor_vin IS NOT NULL", "i.donor_vin <> ''", "i.is_active = true"]
params = []
if q:
where.append("(i.donor_vin ILIKE %s OR i.donor_make ILIKE %s OR i.donor_model ILIKE %s)")
params.extend([f'%{q}%'] * 3)
where_sql = " AND ".join(where)
cur = conn.cursor()
cur.execute(f"SELECT COUNT(DISTINCT i.donor_vin) FROM inventory i WHERE {where_sql}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT i.donor_vin,
MAX(i.donor_make), MAX(i.donor_model), MAX(i.donor_year),
COUNT(*), COALESCE(SUM(i.price_1), 0)
FROM inventory i
WHERE {where_sql}
GROUP BY i.donor_vin
ORDER BY COUNT(*) DESC, i.donor_vin ASC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
data = [{
'donor_vin': r[0], 'donor_make': r[1], 'donor_model': r[2],
'donor_year': r[3], 'pieces': r[4], 'total_value': float(r[5] or 0),
} for r in rows]
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return data, pagination
# ─── Consignments ──────────────────────────────────────────────────────────
_CONSIGNMENT_FIELDS = """
c.id, c.consignor_name, c.consignor_rfc, c.consignor_phone,
c.commission_pct, c.status, c.notes, c.created_at, c.updated_at
"""
def _row_to_consignment(row):
return {
'id': row[0], 'consignor_name': row[1], 'consignor_rfc': row[2],
'consignor_phone': row[3], 'commission_pct': float(row[4] or 0),
'status': row[5], 'notes': row[6],
'created_at': str(row[7]) if row[7] else None,
'updated_at': str(row[8]) if row[8] else None,
}
def list_consignments(conn, status=None, page=1, per_page=50):
"""List consignments with piece counts and amounts."""
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 50) or 50, 1), 200)
where_sql = ""
params = []
if status:
where_sql = "WHERE c.status = %s"
params.append(status)
cur = conn.cursor()
cur.execute(f"SELECT count(*) FROM consignments c {where_sql}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT {_CONSIGNMENT_FIELDS},
COUNT(ci.id),
COALESCE(SUM(ci.agreed_price), 0),
COALESCE(SUM(ci.commission_amount), 0)
FROM consignments c
LEFT JOIN consignment_items ci ON ci.consignment_id = c.id
{where_sql}
GROUP BY c.id
ORDER BY c.created_at DESC, c.id DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
data = []
for r in rows:
cons = _row_to_consignment(r)
cons['pieces'] = r[9]
cons['total_sold'] = float(r[10] or 0)
cons['total_commission'] = float(r[11] or 0)
data.append(cons)
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return data, pagination
def get_consignment(conn, consignment_id):
"""Return a consignment with its items, or None."""
cur = conn.cursor()
cur.execute(f"SELECT {_CONSIGNMENT_FIELDS} FROM consignments c WHERE c.id = %s",
(consignment_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
cons = _row_to_consignment(row)
cur.execute("""
SELECT ci.inventory_id, i.part_number, i.name, ci.agreed_price,
ci.commission_amount, ci.status
FROM consignment_items ci
LEFT JOIN inventory i ON i.id = ci.inventory_id
WHERE ci.consignment_id = %s
ORDER BY ci.id
""", (consignment_id,))
items = []
for r in cur.fetchall():
items.append({
'inventory_id': r[0], 'part_number': r[1], 'name': r[2],
'agreed_price': float(r[3] or 0),
'commission_amount': float(r[4] or 0),
'status': r[5],
})
cur.close()
cons['items'] = items
cons['pieces'] = len(items)
cons['total_sold'] = round(sum(i['agreed_price'] for i in items), 2)
cons['total_commission'] = round(sum(i['commission_amount'] for i in items), 2)
return cons
def create_consignment(conn, data):
"""Create a consignment. Returns the created consignment detail."""
data = data or {}
name = (data.get('consignor_name') or '').strip()
if not name:
raise ValueError('consignor_name required')
cur = conn.cursor()
cur.execute("""
INSERT INTO consignments
(consignor_name, consignor_rfc, consignor_phone, commission_pct, notes, status)
VALUES (%s, %s, %s, %s, %s, 'open')
RETURNING id
""", (name, data.get('consignor_rfc'), data.get('consignor_phone'),
_to_float(data.get('commission_pct'), 0), data.get('notes')))
cid = cur.fetchone()[0]
cur.close()
log_action(conn, 'CONSIGNMENT_CREATE', 'consignment', cid,
new_value={'consignor_name': name})
return get_consignment(conn, cid)
def add_consignment_item(conn, consignment_id, data):
"""Attach an inventory piece to a consignment and set ownership='consigna'."""
data = data or {}
inventory_id = _to_int(data.get('inventory_id'))
if not inventory_id:
raise ValueError('inventory_id required')
cur = conn.cursor()
cur.execute("SELECT commission_pct FROM consignments WHERE id = %s", (consignment_id,))
row = cur.fetchone()
if not row:
cur.close()
raise ValueError('consignment not found')
commission_pct = float(row[0] or 0)
cur.execute("SELECT id FROM inventory WHERE id = %s", (inventory_id,))
if not cur.fetchone():
cur.close()
raise ValueError('inventory item not found')
agreed = _to_float(data.get('agreed_price'), 0)
if data.get('commission_amount') not in (None, ''):
commission = _to_float(data.get('commission_amount'), 0)
else:
commission = round(agreed * commission_pct / 100, 2)
cur.execute("""
INSERT INTO consignment_items
(consignment_id, inventory_id, agreed_price, commission_amount, status)
VALUES (%s, %s, %s, %s, 'in_stock')
RETURNING id
""", (consignment_id, inventory_id, agreed, commission))
item_id = cur.fetchone()[0]
cur.execute("UPDATE inventory SET ownership = 'consigna' WHERE id = %s", (inventory_id,))
cur.close()
log_action(conn, 'CONSIGNMENT_ITEM_ADD', 'consignment', consignment_id,
new_value={'inventory_id': inventory_id, 'agreed_price': agreed})
return {
'id': item_id, 'inventory_id': inventory_id, 'agreed_price': agreed,
'commission_amount': commission, 'status': 'in_stock',
}
def settle_consignment(conn, consignment_id, item_ids=None):
"""Mark consignment items as settled and the consignment as settled.
When item_ids is omitted every not-yet-settled item is settled, so repeated
calls never pay the consignor twice. Returns the settlement summary or None.
"""
cur = conn.cursor()
cur.execute("SELECT consignor_name FROM consignments WHERE id = %s", (consignment_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
consignor_name = row[0]
params = [consignment_id]
sql = ("SELECT id, agreed_price, commission_amount FROM consignment_items "
"WHERE consignment_id = %s")
if item_ids:
sql += " AND id = ANY(%s)"
params.append([_to_int(i) for i in item_ids])
else:
sql += " AND status <> 'settled'"
cur.execute(sql, params)
rows = cur.fetchall()
ids = [r[0] for r in rows]
total_sold = round(sum(float(r[1] or 0) for r in rows), 2)
total_commission = round(sum(float(r[2] or 0) for r in rows), 2)
if ids:
cur.execute("UPDATE consignment_items SET status = 'settled' WHERE id = ANY(%s)", (ids,))
cur.execute("UPDATE consignments SET status = 'settled', updated_at = NOW() WHERE id = %s",
(consignment_id,))
cur.close()
log_action(conn, 'CONSIGNMENT_SETTLE', 'consignment', consignment_id,
new_value={'items': len(ids), 'total_sold': total_sold})
return {
'consignor_name': consignor_name,
'items_count': len(ids),
'total_sold': total_sold,
'total_commission': total_commission,
'total_to_pay_consignor': round(total_sold - total_commission, 2),
}
# ═══════════════════════════════════════════════════════════════════════════
# A5 — Bulk loading, donor intake, reports, consignment statements, labels
# ═══════════════════════════════════════════════════════════════════════════
def create_donor_full(conn, data, branch_id=None):
"""Register a whole donor vehicle at once with its N pieces.
Body keys: vin / make / model / year / mileage_km (donor fields) plus
``pieces`` (list). Each piece inherits the donor fields unless it overrides
them. Pieces without ``part_number`` get an auto-SKU. Atomic: if one piece
fails, the caller rolls back the whole donor.
Returns ``{'donor_vin', 'count', 'pieces': [{'id', 'part_number', 'name'}]}``.
"""
data = data or {}
vin = (data.get('vin') or data.get('donor_vin') or '').strip().upper() or None
if not vin:
raise ValueError('vin required')
make = (data.get('make') or data.get('donor_make') or '').strip() or None
model = (data.get('model') or data.get('donor_model') or '').strip() or None
year_val = data.get('year')
if year_val is None:
year_val = data.get('donor_year')
mileage_val = data.get('mileage_km')
if mileage_val is None:
mileage_val = data.get('donor_mileage_km')
year = _to_int(year_val)
mileage = _to_int(mileage_val)
ownership = (data.get('ownership') or 'propio').strip().lower()
if ownership not in OWNERSHIPS:
raise ValueError("ownership debe ser 'propio' o 'consigna'")
consignment_id = _to_int(data.get('consignment_id'))
pieces = data.get('pieces')
if not isinstance(pieces, list) or not pieces:
raise ValueError('pieces required')
created = []
for idx, raw in enumerate(pieces):
piece = dict(raw or {})
if not (piece.get('name') or '').strip():
raise ValueError(f'pieces[{idx}].name required')
piece.setdefault('donor_vin', vin)
if make:
piece.setdefault('donor_make', make)
if model:
piece.setdefault('donor_model', model)
if year is not None:
piece.setdefault('donor_year', year)
if mileage is not None:
piece.setdefault('donor_mileage_km', mileage)
piece.setdefault('ownership', ownership)
if consignment_id:
piece.setdefault('consignment_id', consignment_id)
piece.setdefault('condition', 'usado')
item = create_piece(conn, piece, branch_id=branch_id)
created.append({
'id': item['id'],
'part_number': item['part_number'],
'name': item['name'],
})
log_action(conn, 'YONKE_DONOR_CREATE', 'inventory',
created[0]['id'] if created else None,
new_value={'donor_vin': vin, 'pieces': len(created)})
return {'donor_vin': vin, 'count': len(created), 'pieces': created}
def allocate_donor_cost(conn, donor_vin, total_cost, method='price'):
"""Spread a donor's total acquisition cost across its pieces.
methods:
* ``price`` (default) — proportional to price_1
* ``equal`` — same amount per piece
* ``by_cost`` — proportional to the pieces' current cost
Falls back to ``equal`` when the weights sum to zero. Values are rounded to
cents and the running cumulative method guarantees the parts add up to
``total_cost`` exactly. Returns ``{'updated': [...], 'total_cost'}`` or None
when the donor has no pieces.
"""
donor_vin = (donor_vin or '').strip().upper()
total = _to_float(total_cost, 0)
if total < 0:
raise ValueError('total_cost no puede ser negativo')
method = (method or 'price').strip().lower()
if method in ('price', 'price_1', 'proportional', 'proporcional'):
key = 'price'
elif method in ('equal', 'igual', 'equitativo'):
key = 'equal'
elif method in ('by_cost', 'cost', 'costo'):
key = 'by_cost'
else:
raise ValueError("method debe ser 'price', 'equal' o 'by_cost'")
cur = conn.cursor()
cur.execute("""
SELECT id, price_1, COALESCE(acquisition_cost, cost, 0)
FROM inventory
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
AND donor_vin = %s
ORDER BY id
""", (donor_vin,))
rows = cur.fetchall()
if not rows:
cur.close()
return None
if key == 'price':
weights = [float(r[1] or 0) for r in rows]
elif key == 'by_cost':
weights = [float(r[2] or 0) for r in rows]
else:
weights = [1.0] * len(rows)
if sum(weights) <= 0:
weights = [1.0] * len(rows)
total_w = sum(weights)
cents = int(round(total * 100))
updated = []
cumulative = 0.0
prev_cents = 0
for i, (row, w) in enumerate(zip(rows, weights)):
cumulative += w
cur_cents = cents if i == len(rows) - 1 else int(round(cents * (cumulative / total_w)))
amount = (cur_cents - prev_cents) / 100.0
prev_cents = cur_cents
cur.execute("UPDATE inventory SET acquisition_cost = %s WHERE id = %s",
(amount, row[0]))
updated.append({'id': row[0], 'acquisition_cost': round(amount, 2)})
cur.close()
log_action(conn, 'YONKE_COST_ALLOCATE', 'inventory', updated[0]['id'],
new_value={'donor_vin': donor_vin, 'total_cost': total, 'method': key})
return {'updated': updated, 'total_cost': round(total, 2)}
def aging_report(conn, days=90):
"""Pieces that have been in the yard for at least ``days`` days.
Returns ``{'data': [...], 'summary': {count, total_value, avg_days}}``.
``days=0`` returns every used piece.
"""
days = _to_int(days, 90)
if days is None or days < 0:
days = 90
cur = conn.cursor()
cur.execute("""
SELECT i.id, i.part_number, i.name, i.condition_grade, i.price_1,
COALESCE(i.acquisition_cost, i.cost, 0) AS cost,
FLOOR(EXTRACT(EPOCH FROM (NOW() - i.created_at)) / 86400)::int AS days_in_yard
FROM inventory i
WHERE i.is_active = true
AND i.condition IN ('usado', 'reconstruido')
AND FLOOR(EXTRACT(EPOCH FROM (NOW() - i.created_at)) / 86400) >= %s
ORDER BY days_in_yard DESC, i.id DESC
""", (days,))
rows = cur.fetchall()
cur.close()
data = [{
'id': r[0], 'part_number': r[1], 'name': r[2],
'condition_grade': r[3], 'price_1': float(r[4] or 0),
'cost': float(r[5] or 0), 'days_in_yard': int(r[6] or 0),
} for r in rows]
count = len(data)
total_value = round(sum(d['price_1'] for d in data), 2)
avg_days = round(sum(d['days_in_yard'] for d in data) / count, 1) if count else 0
return {
'data': data,
'summary': {'count': count, 'total_value': total_value, 'avg_days': avg_days},
}
def summary_report(conn):
"""Yonke dashboard summary: stock value, capital, margins, grade mix."""
cur = conn.cursor()
cur.execute("""
SELECT i.donor_vin, MAX(i.donor_make), MAX(i.donor_model), MAX(i.donor_year),
COUNT(*), COALESCE(SUM(i.price_1), 0)
FROM inventory i
WHERE i.is_active = true AND i.condition IN ('usado', 'reconstruido')
AND i.donor_vin IS NOT NULL AND i.donor_vin <> ''
GROUP BY i.donor_vin
ORDER BY COUNT(*) DESC, i.donor_vin ASC
""")
by_donor = [{
'donor_vin': r[0], 'make': r[1], 'model': r[2], 'year': r[3],
'pieces': r[4], 'total_value': float(r[5] or 0),
} for r in cur.fetchall()]
cur.execute("""
SELECT COALESCE(SUM(si.subtotal), 0),
COALESCE(SUM(COALESCE(si.unit_cost, 0) * si.quantity), 0)
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
JOIN inventory i ON i.id = si.inventory_id
WHERE i.condition IN ('usado', 'reconstruido')
AND COALESCE(s.status, 'completed') = 'completed'
""")
row = cur.fetchone()
revenue = float(row[0] or 0)
cogs = float(row[1] or 0)
margin_pct = round((revenue - cogs) / revenue * 100, 2) if revenue else 0
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(COALESCE(acquisition_cost, cost, 0)), 0)
FROM inventory
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
""")
cap_count, cap_cost = cur.fetchone()
cur.execute("""
SELECT COUNT(*)
FROM inventory i
WHERE i.is_active = true AND i.condition IN ('usado', 'reconstruido')
AND NOT EXISTS (
SELECT 1 FROM inventory_images ii WHERE ii.inventory_id = i.id
)
""")
without_photo = cur.fetchone()[0]
cur.execute("""
SELECT condition_grade, COUNT(*)
FROM inventory
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
AND condition_grade IS NOT NULL
GROUP BY condition_grade
""")
by_grade = dict.fromkeys(GRADES, 0)
for grade, n in cur.fetchall():
if grade in by_grade:
by_grade[grade] = n
cur.close()
return {
'by_donor': by_donor,
'margin': {
'revenue': round(revenue, 2),
'cost': round(cogs, 2),
'margin_pct': margin_pct,
},
'capital': {'pieces': cap_count, 'total_cost': round(float(cap_cost or 0), 2)},
'without_photo': without_photo,
'by_grade': by_grade,
}
def consignment_statement(conn, consignment_id, date_from=None, date_to=None):
"""Account statement for a consignor over an optional period.
Returns ``{consignor_name, period, items, totals}`` or None (404).
Statuses 'sold' and 'settled' count as sold; ``to_pay`` is the agreed price
minus the commission.
"""
cur = conn.cursor()
cur.execute("""
SELECT id, consignor_name, commission_pct, status
FROM consignments WHERE id = %s
""", (consignment_id,))
head = cur.fetchone()
if not head:
cur.close()
return None
consignor_name = head[1]
sql = """
SELECT ci.inventory_id, i.part_number, i.name, ci.agreed_price,
ci.commission_amount, ci.status, ci.created_at
FROM consignment_items ci
LEFT JOIN inventory i ON i.id = ci.inventory_id
WHERE ci.consignment_id = %s
"""
params = [consignment_id]
if date_from:
sql += " AND ci.created_at >= %s::date"
params.append(date_from)
if date_to:
sql += " AND ci.created_at < (%s::date + INTERVAL '1 day')"
params.append(date_to)
sql += " ORDER BY ci.created_at, ci.id"
cur.execute(sql, params)
rows = cur.fetchall()
inv_ids = [r[0] for r in rows if r[0] is not None]
sold_at_map = {}
if inv_ids:
cur.execute("""
SELECT si.inventory_id, MAX(s.created_at)
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
WHERE si.inventory_id = ANY(%s)
AND COALESCE(s.status, 'completed') = 'completed'
GROUP BY si.inventory_id
""", (inv_ids,))
sold_at_map = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
SOLD = ('sold', 'settled')
items = []
sold_count = 0
total_sold = 0.0
total_commission = 0.0
for r in rows:
agreed = float(r[3] or 0)
commission = float(r[4] or 0)
status = r[5]
sold_at = sold_at_map.get(r[0])
if status in SOLD:
sold_count += 1
total_sold += agreed
total_commission += commission
items.append({
'part_number': r[1],
'name': r[2],
'agreed_price': round(agreed, 2),
'commission_amount': round(commission, 2),
'status': status,
'sold_at': str(sold_at) if sold_at else None,
})
return {
'consignor_name': consignor_name,
'commission_pct': float(head[2] or 0),
'period': {'from': date_from, 'to': date_to},
'items': items,
'totals': {
'sold_count': sold_count,
'total_sold': round(total_sold, 2),
'total_commission': round(total_commission, 2),
'to_pay': round(total_sold - total_commission, 2),
},
}
def bulk_create_pieces(conn, csv_text, branch_id=None):
"""Create many pieces from a CSV. One bad row never aborts the rest.
Required column: ``name``. Optional: price_1, cost, condition_grade,
donor_vin, donor_make, donor_model, donor_year, donor_mileage_km, location,
part_number, category_id, condition. Each row runs inside its own savepoint
so a failure only discards that row.
Returns ``{'created': <int>, 'items': [...], 'errors': [{'row', 'error'}]}``.
The first line is the header (row numbering starts at 2).
"""
import csv
import io
text = csv_text if isinstance(csv_text, str) else (csv_text or b'').decode('utf-8-sig', 'replace')
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise ValueError('CSV vacío o sin encabezados')
items = []
errors = []
for row_no, raw in enumerate(reader, start=2):
row = {}
for key, val in (raw or {}).items():
row[(key or '').strip().lower()] = (val or '').strip()
if not any(row.values()):
continue
name = row.get('name')
if not name:
errors.append({'row': row_no, 'error': 'name requerido'})
continue
piece = {
'name': name,
'condition': row.get('condition') or 'usado',
}
for src, dest in (
('part_number', 'part_number'), ('price_1', 'price_1'), ('cost', 'cost'),
('condition_grade', 'condition_grade'), ('donor_vin', 'donor_vin'),
('donor_make', 'donor_make'), ('donor_model', 'donor_model'),
('donor_year', 'donor_year'), ('donor_mileage_km', 'donor_mileage_km'),
('location', 'location'), ('category_id', 'category_id'),
('serial_number', 'serial_number'),
):
if row.get(src):
piece[dest] = row[src]
cur = conn.cursor()
savepoint = False
try:
cur.execute("SAVEPOINT yonke_bulk_row")
savepoint = True
except Exception:
cur.close()
cur = conn.cursor()
try:
item = create_piece(conn, piece, branch_id=branch_id)
if savepoint:
cur.execute("RELEASE SAVEPOINT yonke_bulk_row")
items.append({'id': item['id'], 'part_number': item['part_number'],
'name': item['name']})
except Exception as e:
if savepoint:
try:
cur.execute("ROLLBACK TO SAVEPOINT yonke_bulk_row")
except Exception:
pass
errors.append({'row': row_no, 'error': str(e)})
finally:
cur.close()
log_action(conn, 'YONKE_BULK_CREATE', 'inventory',
items[0]['id'] if items else None,
new_value={'created': len(items), 'errors': len(errors)})
return {'created': len(items), 'items': items, 'errors': errors}
# ─── "Sirve para" (vehicle compatibility) ──────────────────────────────────
def _compat_from_tenant(conn, piece_id):
cur = conn.cursor()
cur.execute("""
SELECT id, model_year_engine_id, make, model, year, engine,
source, confidence
FROM inventory_vehicle_compat
WHERE inventory_id = %s
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0), id
""", (piece_id,))
rows = cur.fetchall()
cur.close()
return [{
'id': r[0],
'model_year_engine_id': r[1],
'make': r[2] or '',
'model': r[3] or '',
'year': r[4],
'engine': r[5] or '',
'source': r[6],
'confidence': float(r[7] or 0),
} for r in rows]
def list_piece_compat(conn, piece_id):
"""Vehicle compatibility for a piece.
Enriches MYE-linked rows through services.inventory_vehicle_compat when the
master DB is reachable; otherwise returns the stored text as-is.
"""
try:
from services import inventory_vehicle_compat as ivc
from tenant_db import get_master_conn
master = get_master_conn()
try:
rows = ivc.get_compatibility(conn, master, piece_id)
finally:
master.close()
return [{
'id': r.get('id'),
'model_year_engine_id': r.get('model_year_engine_id'),
'make': r.get('brand') or r.get('make') or '',
'model': r.get('model') or '',
'year': r.get('year'),
'engine': r.get('engine') or '',
'source': r.get('source'),
'confidence': float(r.get('confidence') or 0),
} for r in rows]
except Exception:
return _compat_from_tenant(conn, piece_id)
def add_piece_compat(conn, piece_id, items):
"""Add text-only vehicle compatibility rows ('sirve para').
Each item is ``{make, model, year}``. Duplicates (same make/model/year) are
skipped. Returns the list of inserted rows.
"""
cur = conn.cursor()
added = []
for item in items or []:
make = (item.get('make') or '').strip() or None
model = (item.get('model') or '').strip() or None
year = _to_int(item.get('year'))
if not make and not model and year is None:
continue
cur.execute("""
SELECT id FROM inventory_vehicle_compat
WHERE inventory_id = %s AND model_year_engine_id IS NULL
AND COALESCE(make, '') = %s AND COALESCE(model, '') = %s
AND COALESCE(year, 0) = %s
""", (piece_id, make or '', model or '', year or 0))
if cur.fetchone():
continue
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, source, confidence)
VALUES (%s, NULL, %s, %s, %s, 'manual', 1.0)
RETURNING id
""", (piece_id, make, model, year))
added.append({
'id': cur.fetchone()[0], 'make': make or '',
'model': model or '', 'year': year,
})
cur.close()
if added:
log_action(conn, 'YONKE_COMPAT_ADD', 'inventory', piece_id,
new_value={'count': len(added)})
return added
# ─── MercadoLibre publish ──────────────────────────────────────────────────
def publish_piece_to_meli(conn, piece_id, category_id=None, price=None, stock=None,
branch_id=None, listing_type=None, shipping_mode=None,
base_url=None):
"""Publish ONE piece to MercadoLibre.
Reuses ``marketplace_external_service.publish_items`` (single-item call).
Degrades with ``{'ok': False, 'error': ...}`` when ML is not configured or
the API rejects the item — never raises for those cases.
``price`` (optional) updates price_1 before publishing; ``stock`` (optional)
loads initial stock when the piece has none and a branch is known, so the ML
listing is publishable.
"""
piece = get_piece(conn, piece_id, branch_id=branch_id)
if not piece:
return {'ok': False, 'external_item_id': None, 'permalink': None,
'not_found': True, 'error': 'Piece not found'}
def _result(ok, error=None, external_item_id=None, permalink=None):
resp = {'ok': ok, 'external_item_id': external_item_id, 'permalink': permalink}
if error:
resp['error'] = error
return resp
try:
from services import marketplace_external_service as meli
except Exception as e: # pragma: no cover - import should always succeed
return _result(False, f'Marketplace service unavailable: {e}')
try:
cfg = meli.get_meli_config(conn)
except Exception as e:
return _result(False, str(e))
if not (cfg.get('meli_access_token') and cfg.get('meli_enabled', True)):
return _result(False, 'MercadoLibre no está configurado')
category_id = (category_id or cfg.get('meli_default_category_id') or '').strip() or None
if not category_id:
return _result(False, 'category_id requerido (body o meli_default_category_id)')
if price is not None:
update_piece(conn, piece_id, {'price_1': price}, branch_id=branch_id)
if stock:
try:
from services.inventory_engine import get_stock, record_initial
qty = int(stock)
if qty > 0 and branch_id and (get_stock(conn, piece_id, branch_id) or 0) <= 0:
record_initial(conn, piece_id, branch_id, qty, piece.get('cost') or 0)
except Exception:
pass
try:
result = meli.publish_items(
conn,
[piece_id],
meli_category_id=category_id,
listing_type_id=listing_type or 'gold_special',
shipping_mode=shipping_mode or cfg.get('meli_shipping_mode') or 'me2',
base_url=base_url,
)
except ValueError as e:
return _result(False, str(e))
except Exception as e:
return _result(False, str(e))
for entry in result.get('success', []) or []:
if str(entry.get('inventory_id')) == str(piece_id):
return _result(True, external_item_id=entry.get('external_item_id'),
permalink=entry.get('permalink'))
failed = result.get('failed') or []
for entry in failed:
if str(entry.get('inventory_id')) == str(piece_id) or entry.get('inventory_id') == 'config':
return _result(False, entry.get('error') or 'ML rechazó la pieza')
return _result(False, 'ML no devolvió un item para la pieza')
# ─── Printable HTML (statement + piece label) ──────────────────────────────
_PRINT_CSS = """
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0; padding: 24px; color: #111; }
h1 { font-size: 20px; margin: 0 0 4px; }
.muted { color: #555; font-size: 12px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 13px; }
th, td { border-bottom: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background: #f3f4f6; }
td.num, th.num { text-align: right; }
.totals { margin-top: 16px; width: auto; }
.totals td { border: none; padding: 2px 8px; }
.totals .label { color: #555; }
.totals .value { font-weight: 600; text-align: right; }
.actions { margin-bottom: 16px; }
@media print { .actions { display: none; } body { padding: 0; } }
"""
def _esc(value):
import html as _html
return _html.escape('' if value is None else str(value))
def _money(value):
"""Format a number as a two-decimal string (no currency symbol)."""
try:
return f"{float(value or 0):.2f}"
except (TypeError, ValueError):
return "0.00"
def _code39_svg(value, module=2, height=70):
"""Render ``value`` as an inline Code39 SVG (no external dependency)."""
table = {
'0': '101001101101', '1': '110100101011', '2': '101100101011',
'3': '110110010101', '4': '101001101011', '5': '110100110101',
'6': '101100110101', '7': '101001011011', '8': '110100101101',
'9': '101100101101', 'A': '110101001011', 'B': '101101001011',
'C': '110110100101', 'D': '101011001011', 'E': '110101100101',
'F': '101101100101', 'G': '101010011011', 'H': '110101001101',
'I': '101101001101', 'J': '101011001101', 'K': '110101010011',
'L': '101101010011', 'M': '110110101001', 'N': '101011010011',
'O': '110101101001', 'P': '101101101001', 'Q': '101010110011',
'R': '110101011001', 'S': '101101011001', 'T': '101011011001',
'U': '110010101011', 'V': '100110101011', 'W': '110011010101',
'X': '100101101011', 'Y': '110010110101', 'Z': '100110110101',
'-': '100101011011', '.': '110010101101', ' ': '100110101101',
'$': '100100100101', '/': '100100101001', '+': '100101001001',
'%': '101001001001', '*': '100101101101',
}
text = ''.join(ch for ch in (value or '').upper() if ch in table)
if not text:
return ''
bits = '0'.join(table[ch] for ch in ('*' + text + '*'))
rects = []
x = module
i = 0
while i < len(bits):
bit = bits[i]
run = 1
while i + run < len(bits) and bits[i + run] == bit:
run += 1
width = run * module
if bit == '1':
rects.append(f'<rect x="{x}" y="0" width="{width}" height="{height}"/>')
x += width
i += run
total_w = x + module
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="{height}" '
f'viewBox="0 0 {total_w} {height}" role="img" aria-label="{_esc(text)}">'
f'<g fill="#000">{"".join(rects)}</g></svg>'
)
def render_statement_html(statement, tenant_name=None):
"""Printable HTML for a consignment statement (same data as the JSON API)."""
if not statement:
return '<!doctype html><html><body><p>Sin datos</p></body></html>'
period = statement.get('period') or {}
period_txt = 'Todo el periodo'
if period.get('from') or period.get('to'):
period_txt = f"{period.get('from') or '...'} a {period.get('to') or '...'}"
totals = statement.get('totals') or {}
rows = []
for it in statement.get('items') or []:
rows.append(
'<tr>'
f"<td>{_esc(it.get('part_number'))}</td>"
f"<td>{_esc(it.get('name'))}</td>"
f"<td class='num'>${_esc(_money(it.get('agreed_price')))}</td>"
f"<td class='num'>${_esc(_money(it.get('commission_amount')))}</td>"
f"<td>{_esc(it.get('status'))}</td>"
f"<td>{_esc(it.get('sold_at') or '')}</td>"
'</tr>'
)
title = _esc(tenant_name or 'Nexus Yonke')
return f"""<!doctype html>
<html lang="es"><head><meta charset="utf-8">
<title>Estado de cuenta - {_esc(statement.get('consignor_name'))}</title>
<style>{_PRINT_CSS}</style></head><body>
<div class="actions"><button onclick="window.print()">Imprimir</button></div>
<h1>Estado de cuenta de consignación</h1>
<div class="muted">{title} &middot; {period_txt}</div>
<p><strong>Consignante:</strong> {_esc(statement.get('consignor_name'))}
&middot; <strong>Comisión:</strong> {_esc(statement.get('commission_pct'))}%</p>
<table>
<thead><tr><th>Código</th><th>Pieza</th><th class="num">Precio acordado</th>
<th class="num">Comisión</th><th>Estado</th><th>Vendida</th></tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="6">Sin movimientos</td></tr>'}</tbody>
</table>
<table class="totals">
<tr><td class="label">Piezas vendidas</td><td class="value">{_esc(totals.get('sold_count'))}</td></tr>
<tr><td class="label">Total vendido</td><td class="value">${_esc(_money(totals.get('total_sold')))}</td></tr>
<tr><td class="label">Comisión</td><td class="value">-${_esc(_money(totals.get('total_commission')))}</td></tr>
<tr><td class="label">A pagar al consignante</td><td class="value">${_esc(_money(totals.get('to_pay')))}</td></tr>
</table>
</body></html>"""
def piece_label_data(conn, piece):
"""Resolve the printable label fields for a piece (adds its barcode)."""
cur = conn.cursor()
cur.execute("SELECT barcode FROM inventory WHERE id = %s", (piece['id'],))
row = cur.fetchone()
cur.close()
code = (row[0] if row else None) or piece.get('part_number') or f"YX-{piece['id']}"
donor = ' '.join(str(p) for p in (
piece.get('donor_make'), piece.get('donor_model'),
piece.get('donor_year') if piece.get('donor_year') is not None else '',
) if p)
return {
'code': code,
'part_number': piece.get('part_number'),
'name': piece.get('name'),
'price_1': piece.get('price_1'),
'condition_grade': piece.get('condition_grade'),
'donor_vin': piece.get('donor_vin'),
'donor_text': donor,
'location': piece.get('location'),
}
def render_label_html(conn, piece, tenant_name=None):
"""Printable label HTML with a Code39 barcode, SKU, name, price and donor."""
data = piece_label_data(conn, piece)
svg = _code39_svg(data['code'])
donor_bits = [b for b in (data['donor_text'], data['donor_vin']) if b]
title = _esc(tenant_name or 'Nexus Yonke')
return f"""<!doctype html>
<html lang="es"><head><meta charset="utf-8">
<title>Etiqueta {_esc(data['code'])}</title>
<style>{_PRINT_CSS}
.label {{ width: 320px; border: 1px dashed #999; padding: 12px; text-align: center; }}
.label .code {{ font-family: monospace; font-size: 14px; letter-spacing: 1px; margin-top: 6px; }}
.label .name {{ font-size: 15px; font-weight: 700; margin: 6px 0; }}
.label .price {{ font-size: 22px; font-weight: 800; }}
.label .donor {{ font-size: 11px; color: #555; margin-top: 6px; }}
.label .meta {{ font-size: 11px; color: #555; }}
</style></head><body>
<div class="actions"><button onclick="window.print()">Imprimir</button></div>
<div class="label">
<div class="meta">{title}</div>
<div class="barcode">{svg or '&#8203;'}</div>
<div class="code">{_esc(data['code'])}</div>
<div class="name">{_esc(data['name'])}</div>
<div class="price">${_esc(_money(data.get('price_1')))}</div>
<div class="meta">Grado {_esc(data.get('condition_grade') or '-')}
{('&middot; ' + _esc(data['location'])) if data.get('location') else ''}</div>
<div class="donor">{_esc(' '.join(donor_bits))}</div>
</div>
</body></html>"""