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

986 lines
36 KiB
Python

# /home/Autopartes/pos/services/b2b_service.py
"""Nexus B2B yonke marketplace: wholesale used parts between deshuesaderos.
A yonke (seller) publishes its used/rebuild pieces to a shared catalog; another
yonke (buyer) places an order. Listings live in the MASTER DB (nexus_autoparts)
so they are visible cross-tenant; the pieces themselves stay in each seller's
tenant DB.
Tables (master DB):
b2b_listings — published pieces (UNIQUE seller_tenant_id+inventory_id)
marketplace_orders — order header (buyer/seller tenant ids, total, status)
marketplace_order_items — order lines (+ listing_id link)
Order lifecycle:
pending → confirmed → shipped → received → completed
pending/confirmed → cancelled
Actor permissions per transition:
buyer : cancelled (from pending), received (from confirmed/shipped)
seller : confirmed (from pending), shipped (from pending/confirmed),
cancelled (from pending/confirmed)
both : completed (from received)
The source states are deliberately permissive (the UI exposes "Marcar
enviado"/"Recibido" a step early) so a valid actor never hits a dead end.
Conventions:
* Master reads/writes go through the `master_conn` passed by the caller
(the blueprint owns commit/rollback), EXCEPT `update_order_status`, which
commits the master transaction itself: on `received` it must persist the
status BEFORE running the cross-tenant side effects, so the order state can
never be rolled back while pieces were already created in another tenant.
* Tenant connections are opened/committed/closed inside this module, because
one order touches two tenants (buyer + seller) at once.
* Everything degrades: side effects at `received` run inside SAVEPOINTs and
never abort the order on failure — failures are returned as `warnings`.
"""
from decimal import Decimal
from psycopg2 import errors as pg_errors
from psycopg2.extras import RealDictCursor
from tenant_db import get_tenant_conn
# ─── Domain constants ──────────────────────────────────────────────────────
LISTING_STATUSES = ('available', 'reserved', 'sold', 'removed')
ORDER_STATUSES = ('pending', 'confirmed', 'shipped', 'received', 'completed', 'cancelled')
# status -> {new_status: (allowed actor roles)}
ORDER_TRANSITIONS = {
'pending': {'confirmed': ('seller',),
'shipped': ('seller',),
'cancelled': ('buyer', 'seller')},
'confirmed': {'shipped': ('seller',),
'received': ('buyer',),
'cancelled': ('seller',)},
'shipped': {'received': ('buyer',)},
'received': {'completed': ('buyer', 'seller')},
'completed': {},
'cancelled': {},
}
USED_CONDITIONS = ('usado', 'reconstruido')
class B2BError(Exception):
"""Base class for all B2B errors (mapped to HTTP by the blueprint)."""
class B2BValidationError(B2BError):
"""Bad input / illegal state transition -> HTTP 400."""
class B2BForbidden(B2BError):
"""Caller is not allowed -> HTTP 403."""
class B2BNotFound(B2BError):
"""Entity missing -> HTTP 404."""
# ─── Small helpers ─────────────────────────────────────────────────────────
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 _to_float(val, default=0.0):
if val is None or val == '':
return default
try:
return float(str(val).replace(',', ''))
except (TypeError, ValueError):
return default
def _jval(v):
"""Make a psycopg2 value JSON-safe (Decimal -> float, date -> isoformat)."""
if isinstance(v, Decimal):
return float(v)
if hasattr(v, 'isoformat'):
return v.isoformat()
return v
def _clean(d):
return {k: _jval(v) for k, v in (d or {}).items()}
def _cur(conn):
return conn.cursor(cursor_factory=RealDictCursor)
def _tenant_name(master_conn, tenant_id):
"""Best-effort tenant display name from the master DB (never raises)."""
if not tenant_id:
return None
try:
cur = _cur(master_conn)
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
row = cur.fetchone()
cur.close()
return row['name'] if row else None
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return None
def is_seller(master_conn, tenant_id):
"""True when the tenant has the seller flag enabled in the master DB."""
try:
cur = _cur(master_conn)
cur.execute("SELECT COALESCE(is_seller, false) AS is_seller FROM tenants WHERE id = %s",
(tenant_id,))
row = cur.fetchone()
cur.close()
return bool(row['is_seller']) if row else False
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return False
def _audit_best_effort(conn, action, entity_type=None, entity_id=None, new_value=None):
try:
from services.audit import log_action
log_action(conn, action, entity_type, entity_id, new_value=new_value)
except Exception:
pass
# ─── Listings: serialization ───────────────────────────────────────────────
_LISTING_FIELDS = (
'id', 'seller_tenant_id', 'seller_inventory_id', 'part_number', 'name',
'condition', 'condition_grade', 'price_wholesale', 'donor_make',
'donor_model', 'donor_year', 'image_url', 'status', 'notes',
'created_at', 'updated_at',
)
_LISTING_COLS = ', '.join(_LISTING_FIELDS)
_LISTING_COLS_L = ', '.join('l.' + f for f in _LISTING_FIELDS)
def _listing_dict(row, seller_name=None, include_private=False):
"""Map a b2b_listings row to the API dict.
`inventory_id` is the public alias of `seller_inventory_id` (the contract
field). Extra fields (condition, notes, seller_tenant_id, timestamps) are
additive and harmless for the UI.
"""
d = _clean(dict(row))
d['inventory_id'] = d.get('seller_inventory_id')
d['price_wholesale'] = float(d.get('price_wholesale') or 0)
if seller_name is not None:
d['seller_name'] = seller_name
if not include_private:
d.pop('seller_tenant_id', None)
return d
# ─── Seller: my listings ───────────────────────────────────────────────────
def list_my_listings(master_conn, tenant_id, status=None):
"""Listings published by `tenant_id` (removed ones hidden by default)."""
cur = _cur(master_conn)
where = ["seller_tenant_id = %s"]
params = [tenant_id]
if status:
status = (status or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
where.append("status = %s")
params.append(status)
else:
where.append("status <> 'removed'")
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings "
f"WHERE {' AND '.join(where)} ORDER BY created_at DESC, id DESC",
params,
)
rows = cur.fetchall()
cur.close()
return [_listing_dict(r, include_private=True) for r in rows]
def get_listing(master_conn, listing_id):
"""Single listing + seller_name, or None."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
cur.close()
if not row:
return None
return _listing_dict(row, seller_name=_tenant_name(master_conn, row['seller_tenant_id']))
# ─── Seller: publish / edit / remove ───────────────────────────────────────
def _fetch_piece(tenant_id, inventory_id):
"""Read the source piece from the seller's tenant DB (+ primary image)."""
conn = get_tenant_conn(tenant_id)
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT id, part_number, name, condition, condition_grade,
donor_make, donor_model, donor_year, image_url, is_active
FROM inventory WHERE id = %s
""", (inventory_id,))
row = cur.fetchone()
if row:
cur.execute("""
SELECT url FROM inventory_images
WHERE inventory_id = %s
ORDER BY is_primary DESC, sort_order ASC, id ASC
LIMIT 1
""", (inventory_id,))
img = cur.fetchone()
if img and img.get('url'):
row = dict(row)
row['image_url'] = img['url']
cur.close()
return dict(row) if row else None
finally:
conn.close()
def publish_listing(master_conn, tenant_id, data):
"""Create or refresh a listing for one of the tenant's used pieces.
Raises B2BForbidden when the tenant is not a seller, B2BValidationError on
bad input / ineligible piece, B2BNotFound when the piece does not exist.
"""
data = data or {}
if not is_seller(master_conn, tenant_id):
raise B2BForbidden(
'Tu cuenta no está habilitada como vendedor (is_seller). '
'Contacta al administrador para activar el modo mayoreo.'
)
inventory_id = _to_int(data.get('inventory_id'))
if not inventory_id:
raise B2BValidationError('inventory_id requerido')
if 'price_wholesale' not in data or data.get('price_wholesale') in (None, ''):
raise B2BValidationError('price_wholesale requerido')
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
piece = _fetch_piece(tenant_id, inventory_id)
if not piece:
raise B2BNotFound('Pieza no encontrada')
condition = (piece.get('condition') or '').strip().lower()
if condition not in USED_CONDITIONS:
raise B2BValidationError(
"Solo se pueden publicar piezas usadas o reconstruidas "
f"(condition actual: {condition or 'desconocida'})"
)
if piece.get('is_active') is False:
raise B2BValidationError('La pieza no está activa en inventario')
notes = (data.get('notes') or '').strip() or None
cur = _cur(master_conn)
cur.execute("""
INSERT INTO b2b_listings
(seller_tenant_id, seller_inventory_id, part_number, name, condition,
condition_grade, price_wholesale, donor_make, donor_model, donor_year,
image_url, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'available', %s)
ON CONFLICT (seller_tenant_id, seller_inventory_id) DO UPDATE SET
part_number = EXCLUDED.part_number,
name = EXCLUDED.name,
condition = EXCLUDED.condition,
condition_grade = EXCLUDED.condition_grade,
price_wholesale = EXCLUDED.price_wholesale,
donor_make = EXCLUDED.donor_make,
donor_model = EXCLUDED.donor_model,
donor_year = EXCLUDED.donor_year,
image_url = EXCLUDED.image_url,
notes = COALESCE(EXCLUDED.notes, b2b_listings.notes),
status = 'available',
updated_at = NOW()
RETURNING id
""", (
tenant_id, inventory_id, piece.get('part_number'), piece.get('name'),
condition or 'usado', piece.get('condition_grade'), price,
piece.get('donor_make'), piece.get('donor_model'), piece.get('donor_year'),
piece.get('image_url'), notes,
))
listing_id = cur.fetchone()['id']
cur.close()
listing = get_listing(master_conn, listing_id)
_audit_publish_best_effort(tenant_id, listing)
return listing
def _audit_publish_best_effort(tenant_id, listing):
try:
conn = get_tenant_conn(tenant_id)
try:
_audit_best_effort(conn, 'B2B_LISTING_PUBLISH', 'b2b_listing',
(listing or {}).get('id'),
{'price_wholesale': (listing or {}).get('price_wholesale')})
conn.commit()
finally:
conn.close()
except Exception:
pass
def update_listing(master_conn, tenant_id, listing_id, data):
"""Update price/status of a listing (owner only). Returns the listing."""
data = data or {}
sets = []
params = []
if 'price_wholesale' in data:
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
sets.append('price_wholesale = %s')
params.append(price)
if 'status' in data:
status = (data.get('status') or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
sets.append('status = %s')
params.append(status)
if 'notes' in data:
sets.append('notes = %s')
params.append((data.get('notes') or '').strip() or None)
if not sets:
raise B2BValidationError('Nada que actualizar (price_wholesale, status, notes)')
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
sets.append('updated_at = NOW()')
params.append(listing_id)
cur.execute(f"UPDATE b2b_listings SET {', '.join(sets)} WHERE id = %s", params)
cur.close()
return get_listing(master_conn, listing_id)
def remove_listing(master_conn, tenant_id, listing_id):
"""Soft-delete a listing (status='removed'). Owner only. Returns bool."""
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
cur.execute(
"UPDATE b2b_listings SET status = 'removed', updated_at = NOW() WHERE id = %s",
(listing_id,),
)
cur.close()
return True
# ─── Buyer: catalog ────────────────────────────────────────────────────────
def list_catalog(master_conn, buyer_tenant_id, filters=None, page=1, per_page=12):
"""Available listings from OTHER tenants, with seller_name and pagination."""
filters = filters or {}
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 12) or 12, 1), 100)
where = ["l.status = 'available'", 'l.seller_tenant_id <> %s']
params = [buyer_tenant_id]
q = (filters.get('q') or '').strip()
if q:
where.append("(l.name ILIKE %s OR l.part_number ILIKE %s "
"OR l.donor_make ILIKE %s OR l.donor_model ILIKE %s)")
params.extend([f'%{q}%'] * 4)
for field, col in (('make', 'l.donor_make'), ('model', 'l.donor_model')):
val = (filters.get(field) or '').strip()
if val:
where.append(f"{col} ILIKE %s")
params.append(f'%{val}%')
year = _to_int(filters.get('year'))
if year:
where.append("l.donor_year = %s")
params.append(year)
grade = (filters.get('grade') or '').strip().upper()
if grade:
where.append("UPPER(l.condition_grade) = %s")
params.append(grade)
where_sql = ' AND '.join(where)
cur = _cur(master_conn)
cur.execute(f"""
SELECT COUNT(*) AS total
FROM b2b_listings l
WHERE {where_sql}
""", params)
total = cur.fetchone()['total'] or 0
cur.execute(f"""
SELECT {_LISTING_COLS_L}, t.name AS seller_name
FROM b2b_listings l
LEFT JOIN tenants t ON t.id = l.seller_tenant_id
WHERE {where_sql}
ORDER BY l.created_at DESC, l.id DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = [_listing_dict(r, seller_name=r.get('seller_name')) for r in rows]
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return items, pagination
# ─── Orders ────────────────────────────────────────────────────────────────
_ORDER_COLS = (
'id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, '
'status, notes, created_at, updated_at'
)
def _order_items(master_conn, order_ids):
"""Bulk-load order lines grouped by order_id."""
if not order_ids:
return {}
cur = _cur(master_conn)
cur.execute("""
SELECT id, order_id, listing_id, part_number, part_name, quantity,
unit_price, subtotal
FROM marketplace_order_items
WHERE order_id = ANY(%s)
ORDER BY order_id, id
""", (list(order_ids),))
rows = cur.fetchall()
cur.close()
out = {}
for r in rows:
item = _clean(dict(r))
item['name'] = item.get('part_name') # UI-friendly alias
out.setdefault(r['order_id'], []).append(item)
return out
def _order_dict(row, items, role):
d = _clean(dict(row))
d['total'] = float(d.get('total') or 0)
d['role'] = role
if role == 'buyer':
d['other_party'] = d.get('seller_name')
d['other_tenant_id'] = d.get('seller_tenant_id')
else:
d['other_party'] = d.get('buyer_name')
d['other_tenant_id'] = d.get('buyer_tenant_id')
d['items'] = items or []
return d
def create_order(master_conn, buyer_tenant_id, items, notes=None):
"""Create a pending order + items and reserve the listings.
All lines must reference available listings from ONE other tenant.
"""
if not isinstance(items, list) or not items:
raise B2BValidationError('items requerido (lista no vacía)')
wanted = []
for it in items:
if not isinstance(it, dict):
raise B2BValidationError('cada item debe ser un objeto {listing_id, quantity}')
listing_id = _to_int(it.get('listing_id'))
qty = _to_int(it.get('quantity'), 1)
if not listing_id:
raise B2BValidationError('listing_id requerido en cada item')
if qty is None or qty < 1:
raise B2BValidationError('quantity debe ser >= 1')
wanted.append({'listing_id': listing_id, 'quantity': qty})
listing_ids = [w['listing_id'] for w in wanted]
cur = _cur(master_conn)
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s) FOR UPDATE",
(listing_ids,),
)
rows = {r['id']: dict(r) for r in cur.fetchall()}
cur.close()
sellers = set()
resolved = []
for w in wanted:
listing = rows.get(w['listing_id'])
if not listing:
raise B2BNotFound(f"Listing {w['listing_id']} no encontrado")
if listing['status'] != 'available':
raise B2BValidationError(
f"Listing {w['listing_id']} no está disponible (status: {listing['status']})")
if listing['seller_tenant_id'] == buyer_tenant_id:
raise B2BValidationError('No puedes comprar tus propias piezas')
sellers.add(listing['seller_tenant_id'])
resolved.append((listing, w['quantity']))
if len(sellers) > 1:
raise B2BValidationError(
'Una orden solo puede tener un vendedor; crea una orden por vendedor')
seller_tenant_id = sellers.pop()
total = round(sum(
_to_float(listing['price_wholesale'], 0) * qty
for listing, qty in resolved), 2)
buyer_name = _tenant_name(master_conn, buyer_tenant_id) or ''
seller_name = _tenant_name(master_conn, seller_tenant_id) or ''
cur = _cur(master_conn)
cur.execute("""
INSERT INTO marketplace_orders
(buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, status, notes)
VALUES (%s, %s, %s, %s, %s, 'pending', %s)
RETURNING id
""", (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, (notes or '').strip() or None))
order_id = cur.fetchone()['id']
order_items = []
for listing, qty in resolved:
price = _to_float(listing['price_wholesale'], 0)
subtotal = round(price * qty, 2)
cur.execute("""
INSERT INTO marketplace_order_items
(order_id, listing_id, part_number, part_name, quantity, unit_price, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (order_id, listing['id'], listing.get('part_number'), listing.get('name'),
qty, price, subtotal))
order_items.append({
'listing_id': listing['id'],
'part_number': listing.get('part_number'),
'part_name': listing.get('name'),
'name': listing.get('name'),
'quantity': qty,
'unit_price': price,
'subtotal': subtotal,
})
cur.execute("""
UPDATE b2b_listings SET status = 'reserved', updated_at = NOW()
WHERE id = ANY(%s)
""", ([listing['id'] for listing, _ in resolved],))
cur.close()
return {
'id': order_id,
'total': total,
'status': 'pending',
'seller_tenant_id': seller_tenant_id,
'buyer_tenant_id': buyer_tenant_id,
'seller_name': seller_name,
'buyer_name': buyer_name,
'items': order_items,
}
def list_orders(master_conn, tenant_id, role):
"""Orders where `tenant_id` is buyer ('buyer') or seller ('seller')."""
col = 'buyer_tenant_id' if role == 'buyer' else 'seller_tenant_id'
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE {col} = %s "
f"ORDER BY created_at DESC, id DESC",
(tenant_id,),
)
rows = cur.fetchall()
cur.close()
items = _order_items(master_conn, [r['id'] for r in rows])
return [_order_dict(r, items.get(r['id']), role) for r in rows]
def get_order(master_conn, tenant_id, order_id):
"""Order detail; only participants. Raises B2BNotFound / B2BForbidden."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s", (order_id,))
row = cur.fetchone()
cur.close()
if not row:
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
raise B2BForbidden('No participas en esta orden')
items = _order_items(master_conn, [order_id])
return _order_dict(row, items.get(order_id), role)
def update_order_status(master_conn, tenant_id, order_id, new_status, branch_id=None):
"""Apply an order status transition (role-checked).
Commits the master transaction itself: when moving to `received` the status
must be durable before the cross-tenant side effects (marking the seller's
piece sold and creating the piece in the buyer's inventory) run.
"""
new_status = (new_status or '').strip().lower()
if new_status not in ORDER_STATUSES:
raise B2BValidationError(
f"status inválido: {new_status} (válidos: {', '.join(ORDER_STATUSES)})")
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s FOR UPDATE",
(order_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
cur.close()
raise B2BForbidden('No participas en esta orden')
current = row['status'] or 'pending'
allowed = ORDER_TRANSITIONS.get(current, {}).get(new_status)
if not allowed:
cur.close()
raise B2BValidationError(f"No se puede pasar de '{current}' a '{new_status}'")
if role not in allowed:
cur.close()
raise B2BForbidden(
f"Rol '{role}' no puede cambiar la orden de '{current}' a '{new_status}'")
cur.execute(
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s",
(new_status, order_id),
)
cur.close()
master_conn.commit()
result = get_order(master_conn, tenant_id, order_id)
result['previous_status'] = current
if new_status == 'received':
extras = _finalize_received(master_conn, dict(row), branch_id=branch_id)
result.update(extras)
return result
# ─── Received: cross-tenant side effects (best-effort) ─────────────────────
def _resolve_branch(conn, branch_id=None):
if branch_id:
return branch_id
try:
cur = conn.cursor()
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
r = cur.fetchone()
cur.close()
return r[0] if r else None
except Exception:
return None
def _mark_seller_piece_sold(conn, inventory_id):
"""Mark the seller's inventory row as sold (SAVEPOINT, never raises).
`inventory` has no `status` column in the standard tenant schema, so we set
`is_active = false` (piece no longer available) when it is absent; if a
tenant ever adds `inventory.status`, we write 'sold' there instead.
"""
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_sold")
try:
cur.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'inventory' AND column_name = 'status' LIMIT 1
""")
has_status = cur.fetchone() is not None
if has_status:
cur.execute("UPDATE inventory SET status = 'sold' WHERE id = %s", (inventory_id,))
else:
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (inventory_id,))
cur.execute("RELEASE SAVEPOINT b2b_sold")
return True
except Exception:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_sold")
except Exception:
pass
return False
finally:
cur.close()
def _insert_buyer_inventory(conn, item, listing, unit_price, warnings):
"""Insert the purchased piece into the buyer's inventory (SAVEPOINT)."""
from services.yonke_service import generate_unique_sku
name = (listing.get('name') or item.get('part_name') or item.get('part_number')
or 'Pieza usada')
candidate = (listing.get('part_number') or item.get('part_number') or '').strip() or None
grade = listing.get('condition_grade')
cur = conn.cursor()
inv_id = None
for _attempt in range(5):
if candidate is None:
try:
candidate = generate_unique_sku(conn, None)
except Exception:
candidate = None
cur.execute("SAVEPOINT b2b_inv")
try:
cur.execute("""
INSERT INTO inventory
(part_number, name, unit, cost, price_1, price_2, price_3, tax_rate,
condition, condition_grade, is_unique_piece,
donor_make, donor_model, donor_year, ownership, image_url, is_active)
VALUES (%s, %s, 'PZA', %s, %s, %s, %s, %s,
'usado', %s, true,
%s, %s, %s, 'propio', %s, true)
RETURNING id
""", (
candidate, name, unit_price, unit_price, unit_price, unit_price, 0.16,
grade, listing.get('donor_make'), listing.get('donor_model'),
listing.get('donor_year'), listing.get('image_url'),
))
inv_id = cur.fetchone()[0]
cur.execute("RELEASE SAVEPOINT b2b_inv")
break
except pg_errors.UniqueViolation:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
candidate = None
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
except Exception:
pass
warnings.append(f"no se pudo crear la pieza en el inventario del comprador: {e}")
cur.close()
return None
cur.close()
return inv_id
def _seed_stock(conn, inv_id, branch_id, qty, cost, warnings):
"""Seed initial stock for a freshly created buyer piece (SAVEPOINT)."""
if not branch_id or qty <= 0:
return
from services.inventory_engine import record_initial
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_stock")
try:
record_initial(conn, inv_id, branch_id, qty, cost=cost)
cur.execute("RELEASE SAVEPOINT b2b_stock")
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_stock")
except Exception:
pass
warnings.append(f"no se registró la operación de stock: {e}")
# Idempotent safety net: the trigger normally creates this row already.
try:
cur.execute("""
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = EXCLUDED.stock
""", (inv_id, branch_id, qty))
except Exception as e:
warnings.append(f"no se pudo fijar el stock inicial: {e}")
cur.close()
def _finalize_received(master_conn, order, branch_id=None):
"""On `received`: sell the listings/seller pieces and stock the buyer.
Best-effort by design: every step runs in its own SAVEPOINT / connection,
failures are collected in `warnings`, and the order is never aborted.
"""
warnings = []
extras = {'listings_sold': 0, 'inventory_created': 0, 'warnings': warnings}
order_id = order['id']
seller_id = order['seller_tenant_id']
buyer_id = order['buyer_tenant_id']
cur = _cur(master_conn)
cur.execute("""
SELECT id, listing_id, part_number, part_name, quantity, unit_price
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
""", (order_id,))
items = [dict(r) for r in cur.fetchall()]
listing_ids = [it['listing_id'] for it in items if it['listing_id']]
listings = {}
if listing_ids:
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s)",
(listing_ids,))
for r in cur.fetchall():
listings[r['id']] = dict(r)
# Legacy rows may lack listing_id: fall back to (seller, part_number).
for it in items:
if it['listing_id'] in listings:
continue
cur.execute(f"""
SELECT {_LISTING_COLS} FROM b2b_listings
WHERE seller_tenant_id = %s AND part_number = %s
ORDER BY (status = 'reserved') DESC, id DESC LIMIT 1
""", (seller_id, it.get('part_number')))
r = cur.fetchone()
if r:
listings[it['listing_id']] = dict(r)
# 1) Mark listings sold (master DB) ------------------------------------
sold = 0
for it in items:
listing = listings.get(it['listing_id']) if it['listing_id'] else None
if not listing and it['listing_id']:
continue
try:
if listing:
cur.execute(
"UPDATE b2b_listings SET status = 'sold', updated_at = NOW() WHERE id = %s",
(listing['id'],))
else:
cur.execute("""
UPDATE b2b_listings SET status = 'sold', updated_at = NOW()
WHERE seller_tenant_id = %s AND part_number = %s AND status = 'reserved'
""", (seller_id, it.get('part_number')))
sold += cur.rowcount
except Exception as e:
master_conn.rollback()
warnings.append(f"no se pudo marcar el listing como vendido: {e}")
cur = _cur(master_conn)
try:
master_conn.commit()
except Exception as e:
warnings.append(f"no se pudo confirmar el estado de los listings: {e}")
extras['listings_sold'] = sold
cur.close()
# 2) Mark the seller's pieces sold (seller tenant DB) ------------------
seller_conn = None
try:
seller_conn = get_tenant_conn(seller_id)
for it in items:
listing = listings.get(it['listing_id'])
inv_id = _to_int((listing or {}).get('seller_inventory_id'))
if inv_id:
_mark_seller_piece_sold(seller_conn, inv_id)
seller_conn.commit()
except Exception as e:
warnings.append(f"no se pudieron marcar las piezas del vendedor: {e}")
if seller_conn is not None:
try:
seller_conn.rollback()
except Exception:
pass
finally:
if seller_conn is not None:
try:
seller_conn.close()
except Exception:
pass
# 3) Create the purchased pieces in the buyer's inventory --------------
buyer_conn = None
try:
buyer_conn = get_tenant_conn(buyer_id)
buyer_branch = _resolve_branch(buyer_conn, branch_id)
created = 0
for it in items:
listing = listings.get(it['listing_id']) or {}
qty = max(_to_int(it.get('quantity'), 1) or 1, 1)
price = _to_float(it.get('unit_price'), 0)
inv_id = _insert_buyer_inventory(buyer_conn, it, listing, price, warnings)
if inv_id:
_seed_stock(buyer_conn, inv_id, buyer_branch, qty, price, warnings)
created += 1
buyer_conn.commit()
extras['inventory_created'] = created
try:
_audit_best_effort(buyer_conn, 'B2B_ORDER_RECEIVED', 'marketplace_order',
order_id, {'inventory_created': created})
buyer_conn.commit()
except Exception:
pass
except Exception as e:
warnings.append(f"no se pudo crear el inventario del comprador: {e}")
if buyer_conn is not None:
try:
buyer_conn.rollback()
except Exception:
pass
finally:
if buyer_conn is not None:
try:
buyer_conn.close()
except Exception:
pass
return extras
# ─── Seller status (UI badge) ──────────────────────────────────────────────
def get_seller_status(master_conn, tenant_id):
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
seller = is_seller(master_conn, tenant_id)
cur = _cur(master_conn)
cur.execute("""
SELECT COUNT(*) AS c FROM b2b_listings
WHERE seller_tenant_id = %s AND status <> 'removed'
""", (tenant_id,))
listings_count = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders
WHERE seller_tenant_id = %s AND status IN ('pending', 'confirmed', 'shipped')
""", (tenant_id,))
incoming = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders WHERE seller_tenant_id = %s
""", (tenant_id,))
total_incoming = cur.fetchone()['c'] or 0
cur.close()
return {
'is_seller': seller,
'listings_count': listings_count,
'incoming_orders': incoming,
'incoming_orders_total': total_incoming,
}