# /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--``. 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': , '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'') x += width i += run total_w = x + module return ( f'' f'{"".join(rects)}' ) def render_statement_html(statement, tenant_name=None): """Printable HTML for a consignment statement (same data as the JSON API).""" if not statement: return '

Sin datos

' 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( '' f"{_esc(it.get('part_number'))}" f"{_esc(it.get('name'))}" f"${_esc(_money(it.get('agreed_price')))}" f"${_esc(_money(it.get('commission_amount')))}" f"{_esc(it.get('status'))}" f"{_esc(it.get('sold_at') or '')}" '' ) title = _esc(tenant_name or 'Nexus Yonke') return f""" Estado de cuenta - {_esc(statement.get('consignor_name'))}

Estado de cuenta de consignación

{title} · {period_txt}

Consignante: {_esc(statement.get('consignor_name'))} · Comisión: {_esc(statement.get('commission_pct'))}%

{''.join(rows) or ''}
CódigoPiezaPrecio acordado ComisiónEstadoVendida
Sin movimientos
Piezas vendidas{_esc(totals.get('sold_count'))}
Total vendido${_esc(_money(totals.get('total_sold')))}
Comisión-${_esc(_money(totals.get('total_commission')))}
A pagar al consignante${_esc(_money(totals.get('to_pay')))}
""" 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""" Etiqueta {_esc(data['code'])}
{title}
{svg or '​'}
{_esc(data['code'])}
{_esc(data['name'])}
${_esc(_money(data.get('price_1')))}
Grado {_esc(data.get('condition_grade') or '-')} {('· ' + _esc(data['location'])) if data.get('location') else ''}
{_esc(' '.join(donor_bits))}
"""