- 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
1699 lines
62 KiB
Python
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} · {period_txt}</div>
|
|
<p><strong>Consignante:</strong> {_esc(statement.get('consignor_name'))}
|
|
· <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 '​'}</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 '-')}
|
|
{('· ' + _esc(data['location'])) if data.get('location') else ''}</div>
|
|
<div class="donor">{_esc(' '.join(donor_bits))}</div>
|
|
</div>
|
|
</body></html>"""
|